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

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

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

View File

@@ -1,7 +1,14 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
State Hub uses a **repo-anchored classification spine** (STATE-WP-0065):
- **Primary anchor:** `managed_repos` + committed `.repo-classification.yaml`
- **Market domain:** derived from classification (`domain` field) — 14 fixed values
- **Workplans:** table `workplans`, `repo_id` required, `topic_id` optional
- **Legacy:** `/workstreams/` REST routes and `workstream_*` MCP tools are aliases
Classification canon lives in `the-custodian/canon/standards/`.
Validation: `api/classification.py` · registration: `make register-from-classification`.
## Quick Reference

View File

@@ -1,5 +1,8 @@
**Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs.
**Domain:** custodian
**Classification:** tooling · infotech (see `.repo-classification.yaml`)
**Repo slug:** state-hub
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a *(legacy optional tag)*
Coordination spine is **repo-anchored** — workplans bind to `repo_id`, market
domain is derived from classification, not from the old coordination-domain model.

View File

@@ -43,7 +43,7 @@ For each file with `status: ready`, `active`, or `blocked`, note pending
**Step 4 — Present brief**
1. **Active workstreams** for `custodian` — title, task counts, blocking decisions
1. **Active workplans** for this repo — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:state-hub]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
@@ -51,12 +51,13 @@ For each file with `status: ready`, `active`, or `blocked`, note pending
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
If no workplans: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> State Hub is a *read model*. Bootstrap tools (`create_workplan`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
> Repo registration uses `.repo-classification.yaml` via `register_repo_from_classification`.
**Session close:**
With MCP tools:

View File

@@ -1,4 +1,4 @@
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
PYTHON ?= python3
@@ -322,5 +322,20 @@ remove-hooks:
gitea-inventory:
$(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json)
## Register/update one repo from .repo-classification.yaml:
## make register-from-classification REPO=state-hub
## make register-from-classification PATH=/path/to/repo
## Optional: DRY_RUN=1
register-from-classification:
@test -n "$(REPO)" -o -n "$(PATH)" || (echo "ERROR: REPO or PATH is required."; exit 1)
$(UV) run python scripts/register_from_classification.py \
$(if $(PATH),--repo-path "$(PATH)",--slug "$(REPO)") \
$(if $(DRY_RUN),--dry-run,)
## Bulk register/update all active repos with accessible local paths
register-from-classification-all:
$(UV) run python scripts/register_from_classification.py --bulk \
$(if $(DRY_RUN),--dry-run,)
clean:
$(COMPOSE) down -v

View File

@@ -111,7 +111,9 @@ custodian register-project # register cwd as a Custodian project
| `make db` | Start postgres container |
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
| `make migrate` | `alembic upgrade head` |
| `make seed` | Insert 6 canonical topics |
| `make seed` | Insert 6 canonical topics (legacy bootstrap) |
| `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` |
| `make register-from-classification-all` | Bulk reclassify all repos with classification files |
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
@@ -125,28 +127,30 @@ custodian register-project # register cwd as a Custodian project
## Database Schema
Five tables in dependency order:
Repo-anchored coordination spine (STATE-WP-0065):
```
topics
└── workstreams
└── tasks (self-FK: parent_task_id)
└── progress_events
decisions (FK: topic_id, workstream_id — at least one required)
domains (14 market domains: infotech, financials, communication, …)
managed_repos (classification: category, domain, capability_tags, business_stake, …)
└── workplans (repo_id required; topic_id optional legacy tag)
└── tasks
└── progress_events
topics (optional cross-repo tag; domain_id → market domain)
decisions (FK: topic_id and/or workplan_id)
```
### Enums
Each registered repo carries a committed `.repo-classification.yaml` (canon
standard v1.0). Registration and reclassification use
`make register-from-classification`.
| Enum | Values |
### Key enums / vocabularies
| Field | Values |
|------|--------|
| `topic_status` | `active` · `paused` · `archived` |
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
| `workplan_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
| `task_priority` | `low` · `medium` · `high` · `critical` |
| `decision_type` | `made` · `pending` |
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` |
| `repo category` | `experimental` · `research` · `project` · `tooling` · `product` · `business` |
| `market domain` | 14 fixed slugs — see `the-custodian/canon/standards/repo-classification.allowed.yaml` |
### Governance constraints encoded in schema
@@ -226,9 +230,11 @@ See `mcp_server/TOOLS.md` for the full tool reference card (30 lines, faster tha
**Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress`
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `update_workstream_status`
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `create_workplan` · `update_workplan_status` · `register_repo_from_classification`
**Resources**: `state://summary` · `state://topics` · `state://workstreams/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
**Resources**: `state://summary` · `state://topics` · `state://workplans/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
Legacy `workstream_*` tool names remain as aliases — see `mcp_server/TOOLS.md`.
---

View File

@@ -2,9 +2,9 @@
## One-Liner
State Hub is the local-first coordination service for Custodian workstreams,
tasks, decisions, progress events, repo metadata, MCP tooling, and dashboard
telemetry.
State Hub is the local-first coordination service for repo-anchored workplans,
tasks, decisions, progress events, repo classification and metadata, MCP
tooling, and dashboard telemetry.
## In Scope
@@ -12,7 +12,8 @@ telemetry.
- PostgreSQL schema and Alembic migrations
- FastMCP server and tool reference
- Observable dashboard
- repo registration and consistency synchronization
- repo registration (classification-driven) and consistency synchronization
- repo classification spine (14 market domains, `.repo-classification.yaml`)
- task-flow engine and flow definitions
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
- State Hub tests, operational docs, policies, prompts, and local infra

290
api/classification.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,106 +1,41 @@
import uuid
from datetime import date, datetime
from typing import Literal
"""Legacy aliases — prefer ``api.schemas.workplan``."""
from api.schemas.workplan import (
ConcurrencyMode,
ExecutionState,
LaunchMode,
WorkplanCreate,
WorkplanRead,
WorkplanStatus,
WorkplanStatusMixin,
WorkplanUpdate,
WorkplanWithDeps,
WorkplanWithTaskCounts,
)
from pydantic import BaseModel, ConfigDict, field_validator
WorkstreamStatus = WorkplanStatus
WorkstreamStatusMixin = WorkplanStatusMixin
WorkstreamCreate = WorkplanCreate
WorkstreamUpdate = WorkplanUpdate
WorkstreamRead = WorkplanRead
WorkstreamWithTaskCounts = WorkplanWithTaskCounts
WorkstreamWithDeps = WorkplanWithDeps
from api.schemas.workstream_dependency import WorkstreamDepStub
from api.workplan_status import normalize_workstream_status
WorkstreamStatus = Literal[
"proposed",
"ready",
"active",
"blocked",
"backlog",
"finished",
"archived",
__all__ = [
"WorkstreamStatus",
"WorkstreamStatusMixin",
"WorkstreamCreate",
"WorkstreamUpdate",
"WorkstreamRead",
"WorkstreamWithTaskCounts",
"WorkstreamWithDeps",
"WorkplanStatus",
"WorkplanStatusMixin",
"WorkplanCreate",
"WorkplanUpdate",
"WorkplanRead",
"WorkplanWithTaskCounts",
"WorkplanWithDeps",
"ExecutionState",
"LaunchMode",
"ConcurrencyMode",
]
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] = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,27 +4,36 @@ title: Domains — Reference
# Domains — Reference
The Domains page shows all registered project domains and the repositories
associated with each one. Domains are the top-level organisational unit of the
Custodian ecosystem.
The Domains page shows the **14 fixed market domains** from the Repo
Classification Standard. These replaced the old ad-hoc coordination domains
(custodian, railiance, markitect, …) in STATE-WP-0065.
---
## What is a domain?
A domain corresponds to one of the six tracked project areas:
A domain is an intended **market / user segment** — not a project org unit.
Each registered repo has exactly one primary domain (from its
`.repo-classification.yaml`), stored on `managed_repos.domain_id`.
| Slug | Project |
| Slug | Segment |
|------|---------|
| `custodian` | The Custodian agent system itself |
| `railiance` | DevOps & infrastructure reliability |
| `markitect` | Knowledge artifact management |
| `coulomb_social` | Co-creation marketplace |
| `personhood` | Rights & obligations framework |
| `foerster_capabilities` | Agency capability taxonomy |
| `infotech` | Developers, platforms, internal tooling users |
| `financials` | Finance, trading, payments |
| `communication` | Messaging, social, collaboration |
| `consumer` | General consumers |
| `health` | Healthcare, wellness |
| `industrials` | Manufacturing, logistics |
| `energy` | Energy sector |
| `utilities` | Utilities infrastructure |
| `materials` | Materials / commodities |
| `realestate` | Property, housing |
| `crypto` | Crypto / web3 |
| `agents` | AI-native agent users |
| `space` | Space industry |
| `government` | Civic, public sector |
Each domain has a slug (URL-friendly identifier), a human-readable name, an
optional description, and a status.
Canon: `the-custodian/canon/standards/repo-classification-standard_v1.0.md`
---
@@ -32,63 +41,21 @@ optional description, and a status.
| Status | Meaning |
|--------|---------|
| **active** | Live domain — topics, workstreams, and tasks are being tracked |
| **archived** | Soft-deleted; no active work. Fails to archive if active topics exist |
| **active** | Live domain — repos and workplans may reference it |
| **archived** | Retired; no new registrations |
---
## KPI row
## Relationship to repos and workplans
Four counters at the top of the page:
| Counter | Meaning |
|---------|---------|
| Total domains | All registered domains regardless of status |
| Active | Domains with status `active` |
| Total repos | Sum of all registered repositories across all domains |
| Newest domain | Name of the most recently created domain |
- **Repos** are the primary anchor — classification file is source of truth.
- **Workplans** require `repo_id`; market domain is derived from the repo.
- **Topics** are optional legacy tags; workplan frontmatter `domain:` may still
use old coordination slugs — the consistency checker maps these to market domains.
---
## Domain cards
## Related
One card per domain showing:
- **Slug** — monospace identifier
- **Status badge** — green `active` or grey `archived`
- **Name** — display name
- **Description** — first 160 characters
- **Repos** — list of registered repositories for this domain, each showing name, local path, and remote URL
---
## RecentlyOnScope
The `Domains / RecentlyOnScope` page generates deterministic Markdown digests
for a selected domain. The range parameter defaults to `1h` and accepts compact
durations such as `15m`, `6h`, or `1d`.
Generated reports are written under the configured State Hub report directory,
defaulting to `reports/recently-on-scope/<domain_slug>/`. The dashboard lists
those Markdown files and previews the raw report content.
---
## Managing domains
Via MCP:
```
create_domain(slug="my_project", name="My Project", description="…")
rename_domain(slug="old_slug", new_slug="new_slug", new_name="New Name")
archive_domain(slug="my_project") # fails if active topics exist
```
Via Makefile:
```bash
make add-domain SLUG=my_project NAME="My Project"
make rename-domain OLD_SLUG=my_project NEW_SLUG=myproject NEW_NAME="My Project"
```
*Domains are never hard-deleted — only archived.*
- **[Repos](/docs/repos)** — portfolio view with category / capability filters
- **[Repo Integration](/docs/repo-integration)** — onboarding with classification file

View File

@@ -5,18 +5,25 @@ title: Repos — Reference
# Repos — Reference
The Repos page shows every repository registered in the Custodian ecosystem,
their SBOM ingestion status, and a domain-grouped coverage map.
their **classification** (category, market domain, capabilities, business stake),
SBOM ingestion status, and a domain-grouped coverage map.
---
## What is a managed repo?
A managed repo is a git repository that has been registered with the state hub
via `custodian register-project` or `register_repo()`. Registration records the
repo's slug, domain, local path, and optional remote URL. Once registered, the
repo receives a `CLAUDE.custodian.md` integration suggestion, an onboarding
workstream with 4 tasks for the repo agent, and is eligible for SBOM ingestion
and the ADR-001 workplan validator.
A managed repo is a git repository registered with State Hub. Registration is
**classification-driven**:
1. Commit `.repo-classification.yaml` per the Repo Classification Standard.
2. Run `make register-from-classification REPO=<slug>` (or use the MCP tool
`register_repo_from_classification`).
The file is the source of truth; the hub stores a validated copy on
`managed_repos` (category, domain, capability_tags, business_stake, provenance).
Legacy `custodian register-project` still works for agent onboarding but should
be followed by classification registration.
For the full onboarding journey see **[Repo Integration](/docs/repo-integration)**.
@@ -27,69 +34,56 @@ For the full onboarding journey see **[Repo Integration](/docs/repo-integration)
| Card | Meaning |
|------|---------|
| **Registered Repos** | Active repos only (status = active) |
| **Domains** | Count of distinct domain slugs across registered repos |
| **Market Domains** | Distinct primary domains across registered repos |
| **Categories** | Distinct work categories (experimental, tooling, product, …) |
| **SBOM Ingested** | Repos with at least one SBOM snapshot |
| **SBOM Gaps** | Repos with no ingested SBOM — red border when > 0 |
---
## Coverage Map
## Portfolio by Category
Groups repos by domain. Each domain block shows:
- **Domain name** with SBOM, EP, and TD chip indicators
- **SBOM chip** — green ✓ if all repos in the domain are ingested, amber ⚠ if any gap exists
- **EPs chip** — count of open/in-progress extension points for this domain
- **TDs chip** — count of open/in-progress technical debt items for this domain
- **Repo table** — one row per repo with SBOM status, package count, and local path
Rows with no SBOM are highlighted in amber.
Groups repos by `category` (experimental, research, project, tooling, product,
business). Each block shows domain, capabilities, business stake, and who
classified the repo (`human` vs `migration`).
---
## Filters
## Coverage Map
Groups repos by **market domain**. Each domain block shows SBOM, EP, and TD
chips plus per-repo classification columns.
---
## Filters (All Repos Table)
| Filter | Effect |
|--------|--------|
| **Domain** | Show repos for a single domain only |
| **Gaps only** | Toggle to show only repos without an ingested SBOM |
| **Market domain** | Primary domain slug |
| **Category** | Repo work category |
| **Capability** | Repos tagged with a capability |
| **Business stake** | Repos affecting a business responsibility area |
| **DoI tier** | Definition of Integrated tier |
| **Gaps only** | Repos without ingested SBOM |
---
## Consistency (C-24)
The ADR-001 consistency checker warns when a registered repo lacks a valid
`.repo-classification.yaml` on disk. Migration-derived rows (`classified_by:
migration`) get an explanatory note until a human-reviewed file is committed.
---
## Onboarding a new repo
See **[Repo Integration](/docs/repo-integration)** for the full journey.
Quick reference:
Use the **Add Repo** form or:
```bash
# From the repo root — registers, writes CLAUDE.custodian.md, creates onboarding tasks
custodian register-project --domain <slug>
# 1. Author classification file in the repo
# 2. Register / reclassify
make register-from-classification PATH=/path/to/repo
make fix-consistency REPO=<slug>
```
## Ingesting a repo's SBOM
```bash
# Auto-detects lockfile at repo root
cd ~/state-hub
make ingest-sbom REPO=<slug> REPO_PATH=/absolute/path
# Multi-ecosystem repo — scan all lockfiles recursively
make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=/absolute/path
```
Supported lockfile formats: `uv.lock`, `requirements.txt`, `package-lock.json`,
`yarn.lock`, `Cargo.lock`, `.terraform.lock.hcl`.
---
## Infra-only repos
Repos with no lockfile (Ansible, shell scripts) can be registered for inventory
purposes. The SBOM gap is expected and can be left as-is. Terraform providers
are auto-detected via `.terraform.lock.hcl` when using `--scan`.
---
*SBOM snapshots are replaced on each ingest — not appended. The last ingestion
timestamp is recorded on the managed_repo row.*

View File

@@ -102,14 +102,28 @@ const repoRows = repos
const integrating = !!integratingBySlug[r.slug];
const doiEntry = doiBySlug[r.slug] ?? null;
const doiTier = doiEntry?.tier ?? "none";
const category = r.category ?? "—";
const capList = r.capability_tags ?? [];
const stakeList = r.business_stake ?? [];
const capTags = capList.length
? capList.slice(0, 3).join(", ") + (capList.length > 3 ? "…" : "")
: "—";
const classifiedBy = r.classified_by ?? "—";
return {
_id: r.id,
_domSlug: domSlug,
_category: category,
_capList: capList,
_stakeList: stakeList,
_hasSbom: hasSbom,
_integrating: integrating,
_doiTier: doiTier,
repo: r.slug,
domain: domName,
category: category,
capTags: capTags,
businessStake: stakeList.length ? stakeList.slice(0, 3).join(", ") : "—",
classifiedBy: classifiedBy,
status: integrating ? "⚙ integrating" : "ready",
path: r.local_path ?? "—",
sbom: hasSbom ? `${lastScan}` : "⚠ not ingested",
@@ -153,9 +167,13 @@ display(html`<div class="kpi-row">
<p class="big-num">${repoRows.length}</p>
</div>
<div class="card">
<h3>Domains</h3>
<h3>Market Domains</h3>
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
</div>
<div class="card">
<h3>Categories</h3>
<p class="big-num">${new Set(repoRows.map(r => r._category).filter(c => c !== "—")).size}</p>
</div>
<div class="card ${integratingCount > 0 ? 'card-integrating' : ''}">
<h3>Integrating</h3>
<p class="big-num">${integratingCount}</p>
@@ -240,6 +258,8 @@ if (domainBlocks.length === 0) {
<table class="repo-table">
<thead><tr>
<th>Repo</th>
<th>Category</th>
<th>Capabilities</th>
<th>DoI Tier</th>
<th>Status</th>
<th>SBOM</th>
@@ -249,6 +269,8 @@ if (domainBlocks.length === 0) {
<tbody>
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
<td class="repo-cell"><code>${r.repo}</code></td>
<td>${r.category}</td>
<td class="path-cell" title=${r.capTags}>${r.capTags}</td>
<td>${_doiBadge(r._doiTier)}</td>
<td>${r._integrating
? html`<span class="chip chip-integrating">⚙ integrating</span>`
@@ -266,18 +288,65 @@ if (domainBlocks.length === 0) {
}
```
## Portfolio by Category
```js
const byCategory = {};
for (const r of repoRows) {
const key = r._category === "—" ? "unclassified" : r._category;
(byCategory[key] = byCategory[key] ?? []).push(r);
}
const categoryBlocks = Object.entries(byCategory).sort(([a], [b]) => a.localeCompare(b));
if (categoryBlocks.length > 0) {
display(html`<h2 style="margin-top:2rem">Portfolio by Category</h2>
<div class="domain-list">
${categoryBlocks.map(([cat, rows]) => html`
<div class="domain-block">
<div class="domain-header">
<span class="domain-name">${cat}</span>
<span class="domain-chips">
<span class="chip chip-neutral">${rows.length} repo(s)</span>
<span class="chip chip-neutral">${new Set(rows.map(r => r._domSlug)).size} domain(s)</span>
</span>
</div>
<table class="repo-table">
<thead><tr>
<th>Repo</th><th>Domain</th><th>Capabilities</th><th>Business stake</th><th>Classified</th>
</tr></thead>
<tbody>
${rows.map(r => html`<tr>
<td class="repo-cell"><code>${r.repo}</code></td>
<td>${r.domain}</td>
<td class="path-cell">${r.capTags}</td>
<td class="path-cell">${r.businessStake}</td>
<td>${r.classifiedBy}</td>
</tr>`)}
</tbody>
</table>
</div>
`)}
</div>`);
}
```
## All Repos Table
```js
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Domain", value: "all"});
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Market domain", value: "all"});
const categoryFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._category).filter(c => c !== "—")).values()], {label: "Category", value: "all"});
const capabilityFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._capList)).values()].sort(), {label: "Capability", value: "all"});
const stakeFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._stakeList)).values()].sort(), {label: "Business stake", value: "all"});
const doiFilter = Inputs.select(["all", "none", "core", "standard", "full"], {label: "DoI tier", value: "all"});
const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false});
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">${domainFilter}${doiFilter}${gapFilter}</div>`);
display(html`<div class="filter-bar">${domainFilter}${categoryFilter}${capabilityFilter}${stakeFilter}${doiFilter}${gapFilter}</div>`);
```
```js
const filteredRows = repoRows.filter(r =>
(domainFilter.value === "all" || r._domSlug === domainFilter.value) &&
(categoryFilter.value === "all" || r._category === categoryFilter.value) &&
(capabilityFilter.value === "all" || r._capList.includes(capabilityFilter.value)) &&
(stakeFilter.value === "all" || r._stakeList.includes(stakeFilter.value)) &&
(doiFilter.value === "all" || r._doiTier === doiFilter.value) &&
(!gapFilter.value || !r._hasSbom)
);
@@ -285,6 +354,10 @@ const filteredRows = repoRows.filter(r =>
display(Inputs.table(filteredRows.map(r => ({
Repo: r.repo,
Domain: r.domain,
Category: r.category,
Capabilities: r.capTags,
"Business stake": r.businessStake,
Classified: r.classifiedBy,
"DoI Tier": DOI_TIER_LABEL[r._doiTier] ?? r._doiTier,
Status: r.status,
SBOM: r.sbom,

View File

@@ -20,6 +20,29 @@ Do not use them as a substitute for formal work definition inside the domain rep
---
## Workplan terminology (STATE-WP-0065)
**Preferred terms:** workplan, `workplan_id`, `/workplans/…`
**Legacy compatibility:** `workstream`, `workstream_id`, `/workstreams/…`, and
`create_workstream` / `update_workstream` MCP tools remain available as aliases.
They call the same implementation as the workplan-named tools and endpoints.
| Preferred (workplan) | Legacy alias (workstream) |
|---|---|
| `create_workplan(repo_id, …)` | `create_workstream(repo_id, …)` |
| `update_workplan` / `update_workplan_status` | `update_workstream` / `update_workstream_status` |
| `list_workplans` | `list_workstreams` |
| `create_workplan_dependency` | `create_dependency` |
| `POST /workplans/` | `POST /workstreams/` (deprecated headers) |
| `workplan_id` query/body field | `workstream_id` (accepted alias) |
Repo classification filters: `list_repos_by_classification(category?, domain?,
capability_tag?, business_stake?)` and extended `list_domain_repos(...)` query
params use `GET /repos/` classification spine fields.
---
## MCP/REST Parity and Failure Handling
The MCP server is a thin stateless HTTP client over the FastAPI service. On
@@ -28,6 +51,10 @@ endpoint they wrap:
| MCP tool | REST endpoint |
|---|---|
| `create_workplan(...)` | `POST /workplans/` |
| `list_workplans(...)` | `GET /workplans/` |
| `update_workplan_status(...)` | `PATCH /workplans/{workplan_id}` |
| `list_repos_by_classification(...)` | `GET /repos/?category=…` |
| `create_workstream(...)` | `POST /workstreams/` |
| `create_task(...)` | `POST /tasks/` |
| `update_task_status(...)` | `PATCH /tasks/{task_id}` |

View File

@@ -358,18 +358,29 @@ def create_topic(slug: str, title: str, domain: str, description: str | None = N
@mcp.tool()
def list_tasks(workstream_id: str, status: str | None = None) -> str:
"""List all tasks in a workstream, optionally filtered by status.
def list_tasks(
workplan_id: str | None = None,
workstream_id: str | None = None,
status: str | None = None,
) -> str:
"""List all tasks in a workplan, optionally filtered by status.
Args:
workstream_id: UUID of the workstream (required).
workplan_id: UUID of the workplan (preferred).
workstream_id: legacy alias for workplan_id.
status: Optional filter — wait | todo | progress | done | cancel.
Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every
matching task. Use this to look up task UUIDs before calling update_task_status,
or to check which tasks from a workplan file are already synced to the DB.
"""
return json.dumps(_get("/tasks", {"workstream_id": workstream_id, "status": status}), indent=2)
parent_id = workplan_id or workstream_id
if not parent_id:
return _json_result(_mcp_error("list_tasks", "workplan_id is required"))
return json.dumps(
_get("/tasks", {"workplan_id": parent_id, "workstream_id": parent_id, "status": status}),
indent=2,
)
@mcp.tool()
@@ -455,37 +466,30 @@ def advance_workstation(entity_type: str, entity_id: str, target_workstation: st
# ---------------------------------------------------------------------------
# Mutate tools
# Workplan helpers (preferred) + legacy workstream aliases
# ---------------------------------------------------------------------------
@mcp.tool()
def create_workstream(
topic_id: str,
def _workplan_id_from_response(payload: dict[str, Any]) -> str | None:
return payload.get("workplan_id") or payload.get("workstream_id") or payload.get("id")
def _create_workplan_impl(
*,
repo_id: str,
title: str,
topic_id: str | None = None,
slug: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
repo_id: str | None = None,
planning_priority: str | None = None,
planning_order: int | None = None,
tool_name: str = "create_workplan",
) -> str:
"""Create a new workstream under a topic and emit a progress_event.
Args:
topic_id: UUID of the parent topic
title: workstream title
slug: URL-friendly identifier (auto-generated from title if omitted)
description: optional longer description
owner: optional owner name
due_date: optional ISO date string (YYYY-MM-DD)
repo_id: UUID of the owning repository (GEMS primary; strongly recommended per ADR-001)
planning_priority: optional planning priority (critical/high/medium/low or repo-local value)
planning_order: optional numeric ordering hint inside a repo/domain
"""
if not slug:
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
ws = _post("/workstreams", {
wp = _post("/workplans", {
"repo_id": repo_id,
"topic_id": topic_id,
"title": title,
"slug": slug,
@@ -493,30 +497,147 @@ def create_workstream(
"owner": owner,
"due_date": due_date,
"status": "active",
"repo_id": repo_id,
"planning_priority": planning_priority,
"planning_order": planning_order,
})
if error := _response_error("create_workstream", ws, ("id",)):
if error := _response_error(tool_name, wp, ("id",)):
return _json_result(error)
progress_error = _emit_progress_event("create_workstream", ws, {
progress_error = _emit_progress_event(tool_name, wp, {
"topic_id": topic_id,
"workstream_id": ws["id"],
"event_type": "workstream_created",
"summary": f"Workstream created: {title}",
"workplan_id": wp["id"],
"workstream_id": wp["id"],
"event_type": "workplan_created",
"summary": f"Workplan created: {title}",
"author": "custodian",
"detail": {"owner": owner, "slug": slug},
"detail": {"owner": owner, "slug": slug, "repo_id": repo_id},
})
if progress_error:
return _json_result(progress_error)
return _json_result(ws)
return _json_result(wp)
def _update_workplan_status_impl(workplan_id: str, status: str, *, tool_name: str) -> str:
wp = _patch(f"/workplans/{workplan_id}", {"status": status})
if error := _response_error(tool_name, wp, ("id", "title")):
return _json_result(error)
progress_error = _emit_progress_event(tool_name, wp, {
"workplan_id": workplan_id,
"workstream_id": workplan_id,
"topic_id": wp.get("topic_id"),
"event_type": "workplan_status_changed",
"summary": f"Workplan status → {status}: {wp['title']}",
"author": "custodian",
})
if progress_error:
return _json_result(progress_error)
return _json_result(wp)
def _update_workplan_impl(
workplan_id: str,
*,
title: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
repo_goal_id: str | None = None,
status: str | None = None,
) -> str:
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if owner is not None:
payload["owner"] = owner
if due_date is not None:
payload["due_date"] = due_date
if status is not None:
payload["status"] = status
if repo_goal_id is not None:
payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None
return _json_result(_patch(f"/workplans/{workplan_id}", payload))
# ---------------------------------------------------------------------------
# Mutate tools
# ---------------------------------------------------------------------------
@mcp.tool()
def create_workplan(
repo_id: str,
title: str,
topic_id: str | None = None,
slug: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
planning_priority: str | None = None,
planning_order: int | None = None,
) -> str:
"""Create a new repo-anchored workplan and emit a progress_event.
Args:
repo_id: UUID of the owning repository (required)
title: workplan title
topic_id: optional topic UUID for cross-repo tagging
slug: URL-friendly identifier (auto-generated from title if omitted)
description: optional longer description
owner: optional owner name
due_date: optional ISO date string (YYYY-MM-DD)
planning_priority: optional planning priority (critical/high/medium/low or repo-local value)
planning_order: optional numeric ordering hint inside a repo
"""
return _create_workplan_impl(
repo_id=repo_id,
title=title,
topic_id=topic_id,
slug=slug,
description=description,
owner=owner,
due_date=due_date,
planning_priority=planning_priority,
planning_order=planning_order,
tool_name="create_workplan",
)
@mcp.tool()
def create_workstream(
title: str,
repo_id: str | None = None,
topic_id: str | None = None,
slug: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
planning_priority: str | None = None,
planning_order: int | None = None,
) -> str:
"""Legacy alias for create_workplan — prefer create_workplan(repo_id=...)."""
if not repo_id:
return _json_result(_mcp_error("create_workstream", "repo_id is required"))
return _create_workplan_impl(
repo_id=repo_id,
title=title,
topic_id=topic_id,
slug=slug,
description=description,
owner=owner,
due_date=due_date,
planning_priority=planning_priority,
planning_order=planning_order,
tool_name="create_workstream",
)
@mcp.tool()
def create_task(
workstream_id: str,
title: str,
workplan_id: str | None = None,
workstream_id: str | None = None,
title: str = "",
priority: str = "medium",
description: str | None = None,
assignee: str | None = None,
@@ -525,15 +646,19 @@ def create_task(
"""Create a new task and emit a progress_event.
Args:
workstream_id: UUID of the parent workstream
workplan_id: UUID of the parent workplan (preferred)
workstream_id: legacy alias for workplan_id
title: task title
priority: low | medium | high | critical
description: optional longer description
assignee: optional assignee name
due_date: optional ISO date string (YYYY-MM-DD)
"""
parent_id = workplan_id or workstream_id
if not parent_id:
return _json_result(_mcp_error("create_task", "workplan_id is required"))
task = _post("/tasks", {
"workstream_id": workstream_id,
"workplan_id": parent_id,
"title": title,
"priority": priority,
"description": description,
@@ -544,7 +669,8 @@ def create_task(
return _json_result(error)
progress_error = _emit_progress_event("create_task", task, {
"workstream_id": workstream_id,
"workplan_id": parent_id,
"workstream_id": parent_id,
"task_id": task["id"],
"event_type": "task_created",
"summary": f"Task created: {title}",
@@ -865,27 +991,81 @@ def add_progress_event(
@mcp.tool()
def update_workstream_status(workstream_id: str, status: str) -> str:
"""Update a workstream's status.
def list_workplans(
repo_id: str | None = None,
topic_id: str | None = None,
status: str | None = None,
owner: str | None = None,
slug: str | None = None,
) -> str:
"""List workplans with optional filters."""
return json.dumps(
_get("/workplans", {
"repo_id": repo_id,
"topic_id": topic_id,
"status": status,
"owner": owner,
"slug": slug,
}),
indent=2,
)
@mcp.tool()
def list_workstreams(
topic_id: str | None = None,
repo_id: str | None = None,
status: str | None = None,
owner: str | None = None,
slug: str | None = None,
) -> str:
"""Legacy alias for list_workplans."""
return list_workplans(
repo_id=repo_id,
topic_id=topic_id,
status=status,
owner=owner,
slug=slug,
)
@mcp.tool()
def update_workplan_status(workplan_id: str, status: str) -> str:
"""Update a workplan's status.
Args:
workstream_id: UUID of the workstream
workplan_id: UUID of the workplan
status: proposed | ready | active | blocked | backlog | finished | archived
"""
ws = _patch(f"/workstreams/{workstream_id}", {"status": status})
if error := _response_error("update_workstream_status", ws, ("id", "title")):
return _json_result(error)
return _update_workplan_status_impl(workplan_id, status, tool_name="update_workplan_status")
progress_error = _emit_progress_event("update_workstream_status", ws, {
"workstream_id": workstream_id,
"topic_id": ws.get("topic_id"),
"event_type": "workstream_status_changed",
"summary": f"Workstream status → {status}: {ws['title']}",
"author": "custodian",
})
if progress_error:
return _json_result(progress_error)
return _json_result(ws)
@mcp.tool()
def update_workstream_status(workstream_id: str, status: str) -> str:
"""Legacy alias for update_workplan_status."""
return _update_workplan_status_impl(workstream_id, status, tool_name="update_workstream_status")
@mcp.tool()
def update_workplan(
workplan_id: str,
title: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
repo_goal_id: str | None = None,
status: str | None = None,
) -> str:
"""Update fields on an existing workplan."""
return _update_workplan_impl(
workplan_id,
title=title,
description=description,
owner=owner,
due_date=due_date,
repo_goal_id=repo_goal_id,
status=status,
)
@mcp.tool()
@@ -898,32 +1078,16 @@ def update_workstream(
repo_goal_id: str | None = None,
status: str | None = None,
) -> str:
"""Update fields on an existing workstream.
Args:
workstream_id: UUID of the workstream
title: new title (optional)
description: new description (optional)
owner: new owner (optional)
due_date: ISO date string YYYY-MM-DD (optional)
repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear)
status: proposed | ready | active | blocked | backlog | finished | archived (optional)
"""
payload: dict = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if owner is not None:
payload["owner"] = owner
if due_date is not None:
payload["due_date"] = due_date
if status is not None:
payload["status"] = status
if repo_goal_id is not None:
payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None
ws = _patch(f"/workstreams/{workstream_id}", payload)
return json.dumps(ws, indent=2)
"""Legacy alias for update_workplan."""
return _update_workplan_impl(
workstream_id,
title=title,
description=description,
owner=owner,
due_date=due_date,
repo_goal_id=repo_goal_id,
status=status,
)
# ---------------------------------------------------------------------------
@@ -951,6 +1115,41 @@ def get_next_steps() -> str:
# Dependency graph tools (S1.4)
# ---------------------------------------------------------------------------
def _create_dependency_impl(
*,
from_workplan_id: str,
to_workplan_id: str | None = None,
to_task_id: str | None = None,
relationship_type: str = "blocks",
description: str | None = None,
) -> str:
dep = _post(f"/workplans/{from_workplan_id}/dependencies", {
"to_workplan_id": to_workplan_id,
"to_task_id": to_task_id,
"relationship_type": relationship_type,
"description": description,
})
return json.dumps(dep, indent=2)
@mcp.tool()
def create_workplan_dependency(
from_workplan_id: str,
to_workplan_id: str | None = None,
to_task_id: str | None = None,
relationship_type: str = "blocks",
description: str | None = None,
) -> str:
"""Record that one workplan depends on another workplan or task."""
return _create_dependency_impl(
from_workplan_id=from_workplan_id,
to_workplan_id=to_workplan_id,
to_task_id=to_task_id,
relationship_type=relationship_type,
description=description,
)
@mcp.tool()
def create_dependency(
from_workstream_id: str,
@@ -959,25 +1158,14 @@ def create_dependency(
relationship_type: str = "blocks",
description: str | None = None,
) -> str:
"""Record that one workstream depends on another workstream or task.
Semantics: from_workstream cannot fully proceed until the target reaches
a satisfactory state. Provide exactly one of to_workstream_id or to_task_id.
Args:
from_workstream_id: UUID of the workstream that has the dependency
to_workstream_id: UUID of the workstream it depends on
to_task_id: UUID of the task it depends on
relationship_type: blocks | starts_after | informs | soft_dependency
description: optional human-readable explanation of the dependency
"""
dep = _post(f"/workstreams/{from_workstream_id}/dependencies", {
"to_workstream_id": to_workstream_id,
"to_task_id": to_task_id,
"relationship_type": relationship_type,
"description": description,
})
return json.dumps(dep, indent=2)
"""Legacy alias for create_workplan_dependency."""
return _create_dependency_impl(
from_workplan_id=from_workstream_id,
to_workplan_id=to_workstream_id,
to_task_id=to_task_id,
relationship_type=relationship_type,
description=description,
)
@mcp.tool()
@@ -990,9 +1178,15 @@ def list_dependencies(workstream_id: str) -> str:
Args:
workstream_id: UUID of the workstream to inspect
"""
edges = _get(f"/workstreams/{workstream_id}/dependencies")
depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id]
blocks = [e for e in edges if e.get("to_workstream_id") == workstream_id]
edges = _get(f"/workplans/{workstream_id}/dependencies")
depends_on = [
e for e in edges
if e.get("from_workplan_id", e.get("from_workstream_id")) == workstream_id
]
blocks = [
e for e in edges
if e.get("to_workplan_id", e.get("to_workstream_id")) == workstream_id
]
return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)
@@ -1227,13 +1421,48 @@ def archive_domain(slug: str) -> str:
@mcp.tool()
def list_domain_repos(domain_slug: str) -> str:
"""List all repositories registered under a domain.
def list_domain_repos(
domain_slug: str,
category: str | None = None,
capability_tag: str | None = None,
business_stake: str | None = None,
) -> str:
"""List repositories registered under a domain, with optional classification filters.
Args:
domain_slug: Domain slug to filter by
category: optional repo classification category
capability_tag: optional capability tag filter
business_stake: optional business stake filter
"""
return json.dumps(_get("/repos", {"domain": domain_slug}), indent=2)
return json.dumps(
_get("/repos", {
"domain": domain_slug,
"category": category,
"capability_tag": capability_tag,
"business_stake": business_stake,
}),
indent=2,
)
@mcp.tool()
def list_repos_by_classification(
category: str | None = None,
domain: str | None = None,
capability_tag: str | None = None,
business_stake: str | None = None,
) -> str:
"""List repos filtered by classification spine fields."""
return json.dumps(
_get("/repos", {
"domain": domain,
"category": category,
"capability_tag": capability_tag,
"business_stake": business_stake,
}),
indent=2,
)
@mcp.tool()
@@ -1275,6 +1504,60 @@ def register_repo(
return json.dumps(repo, indent=2)
@mcp.tool()
def register_repo_from_classification(
repo_slug: str,
dry_run: bool = False,
) -> str:
"""Register or update a repo from its committed ``.repo-classification.yaml``.
Reads the classification file from the repo's local checkout (this host's
registered path), validates against the canon allowed-values, and upserts the
``managed_repo`` row including market-domain assignment.
Args:
repo_slug: Registered repo slug (e.g. 'state-hub', 'the-custodian').
dry_run: If True, report what would change without writing.
"""
import subprocess
script = Path(__file__).parent.parent / "scripts" / "register_from_classification.py"
cmd = [
sys.executable,
str(script),
"--slug",
repo_slug,
"--json",
]
if dry_run:
cmd.append("--dry-run")
result = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return (
f"register-from-classification failed (exit {result.returncode}):\n"
f"{result.stderr or result.stdout or '(no output)'}"
)
summary = data.get("summary", {})
lines = [
f"register-from-classification: {repo_slug}",
(
f"registered={summary.get('registered', 0)} "
f"updated={summary.get('updated', 0)} "
f"skipped={summary.get('skipped', 0)} "
f"invalid={summary.get('invalid', 0)}"
),
]
for row in data.get("results", []):
lines.append(f" [{row.get('outcome')}] {row.get('detail', '')}")
if result.returncode != 0:
lines.append("(completed with invalid rows)")
return "\n".join(lines)
@mcp.tool()
def update_repo_path(repo_slug: str, path: str, host: str | None = None) -> str:
"""Register or update the local filesystem path for a repo on a specific host.

View File

@@ -0,0 +1,735 @@
"""repo-anchored classification spine (STATE-WP-0065 P1)
Adds repo classification columns, replaces coordination domains with 14 market
domains, backfills classifications, re-anchors workplans on repo_id, and renames
workstreams → workplans.
Revision ID: d8e9f0a1b2c3
Revises: c7d8e9f0a1b2
Create Date: 2026-06-22
"""
from __future__ import annotations
import sys
from pathlib import Path
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import ARRAY
# Allow importing migration constants from scripts/.
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from scripts.spine_migration_data import ( # noqa: E402
FALLBACK_REPO_SLUG,
MARKET_DOMAINS,
MARKET_TO_OLD_DOMAIN,
OLD_COORDINATION_DOMAINS,
OLD_DOMAIN_TO_MARKET,
REPO_DISPOSITIONS,
derive_classification,
market_domain_uuid,
migration_provenance,
old_domain_uuid,
)
revision = "d8e9f0a1b2c3"
down_revision = "c7d8e9f0a1b2"
branch_labels = None
depends_on = None
# Tables whose workstream_id column becomes workplan_id.
_WORKPLAN_FK_TABLES: list[tuple[str, str, bool]] = [
("tasks", "workstream_id", True),
("decisions", "workstream_id", False),
("progress_events", "workstream_id", False),
("token_events", "workstream_id", False),
("contributions", "related_workstream_id", False),
("extension_points", "workstream_id", False),
("technical_debt", "workstream_id", False),
("capability_requests", "requesting_workstream_id", False),
("capability_requests", "fulfilling_workstream_id", False),
("workplan_launch_requests", "workstream_id", True),
]
def _pg_array_literal(values: list[str]) -> str:
if not values:
return "ARRAY[]::text[]"
escaped = ", ".join("'" + v.replace("'", "''") + "'" for v in values)
return f"ARRAY[{escaped}]::text[]"
def _insert_market_domains() -> None:
conn = op.get_bind()
for slug, name in MARKET_DOMAINS:
conn.execute(
sa.text(
"""
INSERT INTO domains (id, slug, name, status, created_at, updated_at)
VALUES (:id, :slug, :name, 'active', now(), now())
ON CONFLICT (slug) DO NOTHING
"""
),
{"id": market_domain_uuid(slug), "slug": slug, "name": name},
)
def _backfill_repo_classifications() -> None:
conn = op.get_bind()
provenance = migration_provenance()
rows = conn.execute(
sa.text(
"""
SELECT mr.slug, d.slug AS old_domain_slug
FROM managed_repos mr
JOIN domains d ON d.id = mr.domain_id
"""
)
).fetchall()
for repo_slug, old_domain_slug in rows:
cls = derive_classification(repo_slug, old_domain_slug)
classified_by = cls.get("classified_by", provenance["classified_by"])
market_id = market_domain_uuid(cls["domain"])
conn.execute(
sa.text(
f"""
UPDATE managed_repos
SET category = :category,
domain_id = :domain_id,
secondary_domains = {_pg_array_literal(cls.get('secondary_domains') or [])},
capability_tags = {_pg_array_literal(cls.get('capability_tags') or [])},
business_stake = {_pg_array_literal(cls.get('business_stake') or [])},
business_mechanics = {_pg_array_literal(cls.get('business_mechanics') or [])},
classified_at = :classified_at,
classified_by = :classified_by,
standard_version = :standard_version
WHERE slug = :slug
"""
),
{
"category": cls["category"],
"domain_id": market_id,
"classified_at": provenance["classified_at"],
"classified_by": classified_by,
"standard_version": provenance["standard_version"],
"slug": repo_slug,
},
)
def _market_slug_for_old_domain(old_slug: str) -> str:
return OLD_DOMAIN_TO_MARKET.get(old_slug, "infotech")
def _update_domain_fks_to_market() -> None:
conn = op.get_bind()
market_slugs = [s for s, _ in MARKET_DOMAINS]
# Map known coordination domains on all domain_id FK holders.
old_rows = conn.execute(
sa.text("SELECT slug FROM domains WHERE slug NOT IN :slugs").bindparams(
sa.bindparam("slugs", expanding=True)
),
{"slugs": market_slugs},
).fetchall()
for (old_slug,) in old_rows:
market_id = market_domain_uuid(_market_slug_for_old_domain(old_slug))
for table, nullable in (
("topics", False),
("domain_goals", False),
("capability_catalog", False),
("technical_debt", False),
("extension_points", False),
):
conn.execute(
sa.text(
f"""
UPDATE {table} row
SET domain_id = :market_id
FROM domains old_d
WHERE row.domain_id = old_d.id AND old_d.slug = :old_slug
"""
),
{"market_id": market_id, "old_slug": old_slug},
)
conn.execute(
sa.text(
"""
UPDATE capability_requests cr
SET requesting_domain_id = :market_id
FROM domains old_d
WHERE cr.requesting_domain_id = old_d.id AND old_d.slug = :old_slug
"""
),
{"market_id": market_id, "old_slug": old_slug},
)
conn.execute(
sa.text(
"""
UPDATE capability_requests cr
SET fulfilling_domain_id = :market_id
FROM domains old_d
WHERE cr.fulfilling_domain_id = old_d.id AND old_d.slug = :old_slug
"""
),
{"market_id": market_id, "old_slug": old_slug},
)
# Catch-all: anything still on a non-market domain → infotech.
infotech_id = market_domain_uuid("infotech")
for table in (
"topics",
"domain_goals",
"capability_catalog",
"technical_debt",
"extension_points",
):
conn.execute(
sa.text(
f"""
UPDATE {table} row
SET domain_id = :infotech_id
FROM domains d
WHERE row.domain_id = d.id
AND d.slug NOT IN :market_slugs
"""
).bindparams(sa.bindparam("market_slugs", expanding=True)),
{"infotech_id": infotech_id, "market_slugs": market_slugs},
)
conn.execute(
sa.text(
"""
UPDATE capability_requests cr
SET requesting_domain_id = :infotech_id
FROM domains d
WHERE cr.requesting_domain_id = d.id
AND d.slug NOT IN :market_slugs
"""
).bindparams(sa.bindparam("market_slugs", expanding=True)),
{"infotech_id": infotech_id, "market_slugs": market_slugs},
)
conn.execute(
sa.text(
"""
UPDATE capability_requests cr
SET fulfilling_domain_id = :infotech_id
FROM domains d
WHERE cr.fulfilling_domain_id = d.id
AND d.slug NOT IN :market_slugs
"""
).bindparams(sa.bindparam("market_slugs", expanding=True)),
{"infotech_id": infotech_id, "market_slugs": market_slugs},
)
def _apply_repo_dispositions() -> None:
conn = op.get_bind()
for slug, disp in REPO_DISPOSITIONS.items():
action = disp["action"]
if action == "relink_to":
target = disp["target_slug"]
conn.execute(
sa.text(
"""
UPDATE workstreams ws
SET repo_id = target.id
FROM managed_repos phantom, managed_repos target
WHERE ws.repo_id = phantom.id
AND phantom.slug = :phantom_slug
AND target.slug = :target_slug
"""
),
{"phantom_slug": slug, "target_slug": target},
)
if disp.get("archive"):
conn.execute(
sa.text(
"UPDATE managed_repos SET status = 'archived' WHERE slug = :slug"
),
{"slug": slug},
)
elif action == "collapse_into":
target = disp["target_slug"]
conn.execute(
sa.text(
"""
UPDATE workstreams ws
SET repo_id = target.id
FROM managed_repos dup, managed_repos target
WHERE ws.repo_id = dup.id
AND dup.slug = :dup_slug
AND target.slug = :target_slug
"""
),
{"dup_slug": slug, "target_slug": target},
)
if disp.get("archive"):
conn.execute(
sa.text(
"UPDATE managed_repos SET status = 'archived' WHERE slug = :slug"
),
{"slug": slug},
)
elif action == "archive":
conn.execute(
sa.text(
"""
UPDATE workstreams ws
SET repo_id = fallback.id
FROM managed_repos phantom
JOIN managed_repos fallback ON fallback.slug = :fallback_slug
WHERE ws.repo_id = phantom.id
AND phantom.slug = :slug
"""
),
{"slug": slug, "fallback_slug": FALLBACK_REPO_SLUG},
)
conn.execute(
sa.text(
"UPDATE managed_repos SET status = 'archived' WHERE slug = :slug"
),
{"slug": slug},
)
def _backfill_workstream_repo_ids() -> None:
conn = op.get_bind()
# topic → domain → first active repo (by created_at)
conn.execute(
sa.text(
"""
UPDATE workstreams ws
SET repo_id = sub.repo_id
FROM (
SELECT DISTINCT ON (ws.id)
ws.id AS ws_id,
mr.id AS repo_id
FROM workstreams ws
JOIN topics t ON t.id = ws.topic_id
JOIN managed_repos mr ON mr.domain_id = t.domain_id
WHERE ws.repo_id IS NULL
AND mr.status = 'active'
ORDER BY ws.id, mr.created_at
) sub
WHERE ws.id = sub.ws_id
"""
)
)
# slug-match heuristics (correlated subquery — LATERAL cannot reference outer ws in WHERE)
conn.execute(
sa.text(
"""
UPDATE workstreams ws
SET repo_id = (
SELECT mr.id
FROM managed_repos mr
WHERE mr.status = 'active'
AND (
LOWER(ws.slug) = LOWER(mr.slug)
OR LOWER(REPLACE(ws.slug, '-', '_')) = LOWER(mr.slug)
OR LOWER(ws.slug) LIKE '%' || LOWER(mr.slug) || '%'
OR LOWER(mr.slug) LIKE '%' || LOWER(REPLACE(ws.slug, '-', '_')) || '%'
)
ORDER BY
CASE WHEN LOWER(ws.slug) = LOWER(mr.slug) THEN 0 ELSE 1 END,
mr.created_at
LIMIT 1
)
WHERE ws.repo_id IS NULL
AND EXISTS (
SELECT 1 FROM managed_repos mr
WHERE mr.status = 'active'
AND (
LOWER(ws.slug) = LOWER(mr.slug)
OR LOWER(REPLACE(ws.slug, '-', '_')) = LOWER(mr.slug)
OR LOWER(ws.slug) LIKE '%' || LOWER(mr.slug) || '%'
OR LOWER(mr.slug) LIKE '%' || LOWER(REPLACE(ws.slug, '-', '_')) || '%'
)
)
"""
)
)
# fallback: state-hub
conn.execute(
sa.text(
"""
UPDATE workstreams ws
SET repo_id = mr.id
FROM managed_repos mr
WHERE ws.repo_id IS NULL
AND mr.slug = :fallback_slug
"""
),
{"fallback_slug": FALLBACK_REPO_SLUG},
)
def _delete_old_coordination_domains() -> None:
market_slugs = [s for s, _ in MARKET_DOMAINS]
conn = op.get_bind()
conn.execute(
sa.text("DELETE FROM domains WHERE slug NOT IN :slugs").bindparams(
sa.bindparam("slugs", expanding=True)
),
{"slugs": market_slugs},
)
def _rename_workstream_fk_columns() -> None:
for table, col, _required in _WORKPLAN_FK_TABLES:
new_col = col.replace("workstream", "workplan")
op.alter_column(table, col, new_column_name=new_col)
def _rename_workstream_indexes_on_column(table: str, old_col: str) -> None:
new_col = old_col.replace("workstream", "workplan")
conn = op.get_bind()
old_idx = f"ix_{table}_{old_col}"
new_idx = f"ix_{table}_{new_col}"
exists = conn.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": old_idx},
).fetchone()
if exists:
op.execute(sa.text(f'ALTER INDEX "{old_idx}" RENAME TO "{new_idx}"'))
def _rename_workplan_indexes_back(table: str, workstream_col: str) -> None:
workplan_col = workstream_col.replace("workstream", "workplan")
conn = op.get_bind()
old_idx = f"ix_{table}_{workplan_col}"
new_idx = f"ix_{table}_{workstream_col}"
exists = conn.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": old_idx},
).fetchone()
if exists:
op.execute(sa.text(f'ALTER INDEX "{old_idx}" RENAME TO "{new_idx}"'))
def upgrade() -> None:
# (a) classification columns on managed_repos
op.add_column("managed_repos", sa.Column("category", sa.String(length=50), nullable=True))
op.add_column(
"managed_repos",
sa.Column("secondary_domains", ARRAY(sa.Text()), nullable=True),
)
op.add_column(
"managed_repos",
sa.Column("capability_tags", ARRAY(sa.Text()), nullable=True),
)
op.add_column(
"managed_repos",
sa.Column("business_stake", ARRAY(sa.Text()), nullable=True),
)
op.add_column(
"managed_repos",
sa.Column("business_mechanics", ARRAY(sa.Text()), nullable=True),
)
op.add_column("managed_repos", sa.Column("classified_at", sa.Date(), nullable=True))
op.add_column(
"managed_repos", sa.Column("classified_by", sa.String(length=50), nullable=True)
)
op.add_column(
"managed_repos",
sa.Column("standard_version", sa.String(length=20), nullable=True),
)
# (b) insert 14 market domains (old coordination domains remain for now)
_insert_market_domains()
# (c) backfill classification
_backfill_repo_classifications()
# (d)(e)(f) point FKs at market domains
_update_domain_fks_to_market()
# (g) backfill workstreams.repo_id
_backfill_workstream_repo_ids()
# (h) discrepancy resolution
_apply_repo_dispositions()
# (i) topic_id nullable
op.alter_column("workstreams", "topic_id", nullable=True)
# (j) repo_id NOT NULL (orphans already assigned state-hub)
op.alter_column("workstreams", "repo_id", nullable=False)
# (k) rename workstreams → workplans
op.rename_table("workstreams", "workplans")
op.execute('ALTER INDEX IF EXISTS "ix_workstreams_repo_id" RENAME TO "ix_workplans_repo_id"')
op.execute(
'ALTER INDEX IF EXISTS "ix_workstreams_execution_state" '
'RENAME TO "ix_workplans_execution_state"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workstreams_launch_mode" '
'RENAME TO "ix_workplans_launch_mode"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workstreams_concurrency_mode" '
'RENAME TO "ix_workplans_concurrency_mode"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workstreams_queue_rank" '
'RENAME TO "ix_workplans_queue_rank"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workstreams_execution_group" '
'RENAME TO "ix_workplans_execution_group"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workstreams_scheduled_for" '
'RENAME TO "ix_workplans_scheduled_for"'
)
# (l) workstream_id → workplan_id on dependent tables
for table, col, _ in _WORKPLAN_FK_TABLES:
_rename_workstream_indexes_on_column(table, col)
_rename_workstream_fk_columns()
# update decision check constraint name
op.drop_constraint("ck_decisions_topic_or_workstream", "decisions", type_="check")
op.create_check_constraint(
"ck_decisions_topic_or_workplan",
"decisions",
"topic_id IS NOT NULL OR workplan_id IS NOT NULL",
)
# (m) workstream_dependencies → workplan_dependencies
op.rename_table("workstream_dependencies", "workplan_dependencies")
op.alter_column(
"workplan_dependencies",
"from_workstream_id",
new_column_name="from_workplan_id",
)
op.alter_column(
"workplan_dependencies",
"to_workstream_id",
new_column_name="to_workplan_id",
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workstream_dependencies_from_workstream_id" '
'RENAME TO "ix_workplan_dependencies_from_workplan_id"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workstream_dependencies_to_workstream_id" '
'RENAME TO "ix_workplan_dependencies_to_workplan_id"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workstream_dependencies_to_task_id" '
'RENAME TO "ix_workplan_dependencies_to_task_id"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workstream_dependencies_relationship_type" '
'RENAME TO "ix_workplan_dependencies_relationship_type"'
)
op.execute(
'ALTER INDEX IF EXISTS "uq_ws_dep_workstream_target" '
'RENAME TO "uq_wp_dep_workplan_target"'
)
op.execute(
'ALTER INDEX IF EXISTS "uq_ws_dep_task_target" '
'RENAME TO "uq_wp_dep_task_target"'
)
op.drop_constraint("ck_ws_dep_exactly_one_target", "workplan_dependencies", type_="check")
op.create_check_constraint(
"ck_wp_dep_exactly_one_target",
"workplan_dependencies",
"(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)",
)
# (n) remove old coordination domain rows
_delete_old_coordination_domains()
def _insert_old_coordination_domains() -> None:
conn = op.get_bind()
for slug, name in OLD_COORDINATION_DOMAINS:
conn.execute(
sa.text(
"""
INSERT INTO domains (id, slug, name, status, created_at, updated_at)
VALUES (:id, :slug, :name, 'active', now(), now())
ON CONFLICT (slug) DO NOTHING
"""
),
{"id": old_domain_uuid(slug), "slug": slug, "name": name},
)
def _restore_domain_fks_to_coordination() -> None:
conn = op.get_bind()
for market_slug, old_slug in MARKET_TO_OLD_DOMAIN.items():
conn.execute(
sa.text(
"""
UPDATE managed_repos
SET domain_id = :old_id
FROM domains market_d
WHERE managed_repos.domain_id = market_d.id
AND market_d.slug = :market_slug
"""
),
{
"old_id": old_domain_uuid(old_slug),
"market_slug": market_slug,
},
)
conn.execute(
sa.text(
"""
UPDATE topics
SET domain_id = :old_id
FROM domains market_d
WHERE topics.domain_id = market_d.id
AND market_d.slug = :market_slug
"""
),
{
"old_id": old_domain_uuid(old_slug),
"market_slug": market_slug,
},
)
conn.execute(
sa.text(
"""
UPDATE domain_goals
SET domain_id = :old_id
FROM domains market_d
WHERE domain_goals.domain_id = market_d.id
AND market_d.slug = :market_slug
"""
),
{
"old_id": old_domain_uuid(old_slug),
"market_slug": market_slug,
},
)
def downgrade() -> None:
# Re-insert legacy coordination domains before deleting market rows.
_insert_old_coordination_domains()
_restore_domain_fks_to_coordination()
market_slugs = [s for s, _ in MARKET_DOMAINS]
conn = op.get_bind()
conn.execute(
sa.text("DELETE FROM domains WHERE slug IN :slugs").bindparams(
sa.bindparam("slugs", expanding=True)
),
{"slugs": market_slugs},
)
# workplan_dependencies → workstream_dependencies
op.drop_constraint("ck_wp_dep_exactly_one_target", "workplan_dependencies", type_="check")
op.create_check_constraint(
"ck_ws_dep_exactly_one_target",
"workplan_dependencies",
"(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)",
)
op.alter_column(
"workplan_dependencies",
"to_workplan_id",
new_column_name="to_workstream_id",
)
op.alter_column(
"workplan_dependencies",
"from_workplan_id",
new_column_name="from_workstream_id",
)
op.rename_table("workplan_dependencies", "workstream_dependencies")
op.execute(
'ALTER INDEX IF EXISTS "ix_workplan_dependencies_from_workplan_id" '
'RENAME TO "ix_workstream_dependencies_from_workstream_id"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workplan_dependencies_to_workplan_id" '
'RENAME TO "ix_workstream_dependencies_to_workstream_id"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workplan_dependencies_to_task_id" '
'RENAME TO "ix_workstream_dependencies_to_task_id"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workplan_dependencies_relationship_type" '
'RENAME TO "ix_workstream_dependencies_relationship_type"'
)
op.execute(
'ALTER INDEX IF EXISTS "uq_wp_dep_workplan_target" '
'RENAME TO "uq_ws_dep_workstream_target"'
)
op.execute(
'ALTER INDEX IF EXISTS "uq_wp_dep_task_target" '
'RENAME TO "uq_ws_dep_task_target"'
)
op.drop_constraint("ck_decisions_topic_or_workplan", "decisions", type_="check")
for table, col, _ in reversed(_WORKPLAN_FK_TABLES):
new_col = col.replace("workstream", "workplan")
op.alter_column(table, new_col, new_column_name=col)
_rename_workplan_indexes_back(table, col)
op.rename_table("workplans", "workstreams")
op.execute('ALTER INDEX IF EXISTS "ix_workplans_repo_id" RENAME TO "ix_workstreams_repo_id"')
op.execute(
'ALTER INDEX IF EXISTS "ix_workplans_execution_state" '
'RENAME TO "ix_workstreams_execution_state"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workplans_launch_mode" '
'RENAME TO "ix_workstreams_launch_mode"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workplans_concurrency_mode" '
'RENAME TO "ix_workstreams_concurrency_mode"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workplans_queue_rank" '
'RENAME TO "ix_workstreams_queue_rank"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workplans_execution_group" '
'RENAME TO "ix_workstreams_execution_group"'
)
op.execute(
'ALTER INDEX IF EXISTS "ix_workplans_scheduled_for" '
'RENAME TO "ix_workstreams_scheduled_for"'
)
op.alter_column("workstreams", "repo_id", nullable=True)
op.alter_column("workstreams", "topic_id", nullable=False)
# Un-archive disposition repos (best-effort)
for slug in REPO_DISPOSITIONS:
conn.execute(
sa.text(
"UPDATE managed_repos SET status = 'active' WHERE slug = :slug"
),
{"slug": slug},
)
op.drop_column("managed_repos", "standard_version")
op.drop_column("managed_repos", "classified_by")
op.drop_column("managed_repos", "classified_at")
op.drop_column("managed_repos", "business_mechanics")
op.drop_column("managed_repos", "business_stake")
op.drop_column("managed_repos", "capability_tags")
op.drop_column("managed_repos", "secondary_domains")
op.drop_column("managed_repos", "category")
op.create_check_constraint(
"ck_decisions_topic_or_workstream",
"decisions",
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
)

View File

@@ -26,6 +26,7 @@ Checks:
C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph
C-22 task-description-drift WARN Yes Task description/content differs between file and DB
C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is progress or wait
C-24 repo-classification-missing WARN No Registered repo lacks a valid .repo-classification.yaml on disk
Usage:
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
@@ -42,7 +43,7 @@ Exit codes (--remote --all scheduled sweep):
1 — automation error: API unreachable, repo list fetch failed, C-00 on
any repo, or other infrastructure fault that prevented a full run
Assessment failures (C-01..C-23 except C-00) are repo hygiene gaps recorded
Assessment failures (C-01..C-24 except C-00) are repo hygiene gaps recorded
in the sweep report for later improvement. They do not fail the scheduler.
Agent/operator Make wrappers normalize exit code 2 to shell success while
@@ -78,6 +79,11 @@ from api.workplan_status import ( # noqa: E402
normalize_workstream_status as _normalize_workstream_status,
ready_review_status,
)
from api.classification import ( # noqa: E402
CLASSIFICATION_FILENAME,
load_classification_file,
resolve_topic_domain_slug,
)
from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402
from api.task_status import ( # noqa: E402
CANONICAL_TASK_STATUSES,
@@ -713,6 +719,31 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
repo_dir = Path(repo_path)
workplans_dir = repo_dir / "workplans"
repo_market_domain = str(repo.get("domain_slug") or "").strip()
# C-24: repo classification file missing or invalid (always WARN — migration rows too)
class_data, class_errors, class_warnings = load_classification_file(repo_dir)
if class_data is None:
classified_by = str(repo.get("classified_by") or "").strip()
if class_errors:
detail = "; ".join(class_errors)
else:
detail = f"{CLASSIFICATION_FILENAME} missing on disk"
if classified_by == "migration":
detail = f"{detail} (DB row is migration-derived — commit a human-reviewed file when ready)"
report.add(
severity="WARN",
check_id="C-24",
message=f"Repo classification gap: {detail}",
fixable=False,
)
for warning in class_warnings:
report.add(
severity="WARN",
check_id="C-24",
message=f"Repo classification advisory: {warning}",
fixable=False,
)
# C-01: workplans/ directory missing
if not workplans_dir.is_dir():
@@ -804,6 +835,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
"body": body,
"repo_id": repo_id,
"domain": file_domain,
"repo_market_domain": repo_market_domain,
},
)
continue
@@ -1708,6 +1740,7 @@ def fix_repo(
wp_file = Path(ctx["wp_file"])
meta = ctx["meta"]
domain = ctx["domain"]
repo_market_domain = str(ctx.get("repo_market_domain") or "").strip()
repo_id_val = ctx["repo_id"]
body = ctx.get("body", "")
wp_id = str(meta.get("id", "")).strip()
@@ -1717,17 +1750,23 @@ def fix_repo(
if status not in VALID_WP_STATUSES:
status = "active"
# Find topic_id for this domain
# Find topic_id — workplan frontmatter may still use legacy
# coordination slugs (e.g. custodian); map to market domain first.
topic_domain = resolve_topic_domain_slug(
domain,
repo_market_domain=repo_market_domain or None,
)
topics = _api_get(api_base, "/topics")
topic_id = None
if isinstance(topics, list):
for t in topics:
if t.get("domain_slug") == domain:
if t.get("domain_slug") == topic_domain:
topic_id = t["id"]
break
if topic_id is None:
report.fixes_applied.append(
f"C-06 SKIP {wp_id}: no topic found for domain '{domain}'"
f"C-06 SKIP {wp_id}: no topic found for domain "
f"'{topic_domain}' (workplan domain={domain!r})"
)
continue

View File

@@ -0,0 +1,635 @@
#!/usr/bin/env python3
"""Idempotent registration from committed ``.repo-classification.yaml`` (STATE-WP-0065 P3).
Reads classification from a repo checkout, validates against the canon allowed-values,
and upserts the ``managed_repos`` row (create or update classification + market domain).
Usage:
python scripts/register_from_classification.py --repo-path /path/to/repo [--dry-run]
python scripts/register_from_classification.py --slug state-hub [--dry-run]
python scripts/register_from_classification.py --bulk [--dry-run]
python scripts/register_from_classification.py --help
"""
from __future__ import annotations
import argparse
import asyncio
import json
import re
import socket
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Any, Literal
_REPO_ROOT = Path(__file__).resolve().parent.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from sqlalchemy import select # noqa: E402
from api.classification import ( # noqa: E402
CLASSIFICATION_FILENAME,
ClassificationData,
load_classification_file,
)
from api.config import settings # noqa: E402
from api.database import async_session_factory, engine # noqa: E402
from api.models.domain import Domain # noqa: E402
from api.models.managed_repo import ManagedRepo # noqa: E402
try:
import httpx
_HAS_HTTPX = True
except ImportError:
_HAS_HTTPX = False
Outcome = Literal["registered", "updated", "skipped", "invalid"]
@dataclass
class RowResult:
slug: str
path: str
outcome: Outcome
detail: str = ""
warnings: list[str] = field(default_factory=list)
@dataclass
class RegistrationReport:
results: list[RowResult] = field(default_factory=list)
def add(self, result: RowResult) -> None:
self.results.append(result)
def counts(self) -> dict[str, int]:
totals = {"registered": 0, "updated": 0, "skipped": 0, "invalid": 0}
for row in self.results:
totals[row.outcome] = totals.get(row.outcome, 0) + 1
return totals
def render_text(self) -> str:
lines = ["register-from-classification report", ""]
for row in self.results:
lines.append(f" [{row.outcome:10}] {row.slug:30} {row.detail}")
for warning in row.warnings:
lines.append(f" warn: {warning}")
counts = self.counts()
lines.append("")
lines.append(
"Summary: "
f"registered={counts['registered']} "
f"updated={counts['updated']} "
f"skipped={counts['skipped']} "
f"invalid={counts['invalid']}"
)
return "\n".join(lines)
def to_dict(self) -> dict[str, Any]:
return {
"summary": self.counts(),
"results": [
{
"slug": r.slug,
"path": r.path,
"outcome": r.outcome,
"detail": r.detail,
"warnings": r.warnings,
}
for r in self.results
],
}
def _slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return slug or "repo"
def _parse_classified_at(value: str | None) -> date | None:
if not value:
return None
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
return None
def _git_value(repo_path: Path, args: list[str]) -> str | None:
try:
return subprocess.check_output(
["git", *args],
cwd=repo_path,
stderr=subprocess.DEVNULL,
text=True,
).strip() or None
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return None
def _git_root(path: Path) -> Path:
root = _git_value(path, ["rev-parse", "--show-toplevel"])
return Path(root) if root else path.resolve()
def _resolve_repo_path_for_host(repo: ManagedRepo) -> str | None:
hostname = socket.gethostname()
host_paths = repo.host_paths or {}
path = host_paths.get(hostname) or repo.local_path
if path and Path(path).is_dir():
return path
for candidate in host_paths.values():
if candidate and Path(candidate).is_dir():
return candidate
return None
def _classification_changed(repo: ManagedRepo, data: ClassificationData, domain_id) -> bool:
if repo.domain_id != domain_id:
return True
fields = (
("category", data.category),
("secondary_domains", data.secondary_domains or None),
("capability_tags", data.capability_tags or None),
("business_stake", data.business_stake or None),
("business_mechanics", data.business_mechanics or None),
("classified_at", _parse_classified_at(data.classified_at)),
("classified_by", data.classified_by),
("standard_version", data.standard_version),
)
for attr, new_val in fields:
if getattr(repo, attr) != new_val:
return True
return False
def _apply_classification(repo: ManagedRepo, data: ClassificationData, domain_id) -> None:
repo.domain_id = domain_id
repo.category = data.category
repo.secondary_domains = data.secondary_domains or None
repo.capability_tags = data.capability_tags or None
repo.business_stake = data.business_stake or None
repo.business_mechanics = data.business_mechanics or None
repo.classified_at = _parse_classified_at(data.classified_at)
repo.classified_by = data.classified_by
repo.standard_version = data.standard_version
async def _get_domain_id(session, market_slug: str):
result = await session.execute(select(Domain).where(Domain.slug == market_slug))
domain = result.scalar_one_or_none()
if domain is None:
raise ValueError(f"Market domain '{market_slug}' not found in domains table")
return domain.id
async def _get_repo_by_slug(session, slug: str) -> ManagedRepo | None:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
return result.scalar_one_or_none()
def _api_request(
method: str,
path: str,
*,
api_base: str,
body: dict | None = None,
) -> tuple[int, Any]:
if not _HAS_HTTPX:
return (0, {"_error": "httpx not installed"})
url = api_base.rstrip("/") + path
try:
with httpx.Client(timeout=30.0) as client:
response = client.request(method, url, json=body)
if response.status_code == 204:
return response.status_code, None
try:
payload = response.json()
except Exception:
payload = {"_raw": response.text}
return response.status_code, payload
except httpx.HTTPError as exc:
return (0, {"_error": str(exc)})
async def _upsert_via_db(
*,
slug: str,
repo_path: Path,
data: ClassificationData,
dry_run: bool,
report: RegistrationReport,
) -> None:
git_root = _git_root(repo_path)
remote_url = _git_value(git_root, ["remote", "get-url", "origin"])
git_fingerprint = _git_value(git_root, ["rev-list", "--max-parents=0", "HEAD"])
hostname = socket.gethostname()
display_name = git_root.name.replace("-", " ").replace("_", " ").title()
async with async_session_factory() as session:
try:
domain_id = await _get_domain_id(session, data.domain)
except ValueError as exc:
if dry_run:
report.add(
RowResult(
slug,
str(git_root),
"skipped",
f"dry-run: {exc}",
)
)
return
report.add(RowResult(slug, str(git_root), "invalid", str(exc)))
return
repo = await _get_repo_by_slug(session, slug)
if repo is None:
if dry_run:
report.add(
RowResult(
slug,
str(git_root),
"registered",
f"would create repo under domain '{data.domain}' (dry-run)",
)
)
return
repo = ManagedRepo(
domain_id=domain_id,
slug=slug,
name=display_name,
local_path=str(git_root),
host_paths={hostname: str(git_root)},
remote_url=remote_url,
git_fingerprint=git_fingerprint,
status="active",
)
_apply_classification(repo, data, domain_id)
session.add(repo)
await session.commit()
report.add(
RowResult(slug, str(git_root), "registered", f"domain={data.domain}")
)
return
warnings: list[str] = []
if not _classification_changed(repo, data, domain_id):
if repo.local_path != str(git_root):
if dry_run:
report.add(
RowResult(
slug,
str(git_root),
"skipped",
"classification unchanged; would refresh local_path (dry-run)",
)
)
return
repo.local_path = str(git_root)
host_paths = dict(repo.host_paths or {})
host_paths[hostname] = str(git_root)
repo.host_paths = host_paths
if remote_url:
repo.remote_url = remote_url
if git_fingerprint:
repo.git_fingerprint = git_fingerprint
await session.commit()
report.add(
RowResult(slug, str(git_root), "skipped", "paths refreshed only")
)
return
report.add(
RowResult(slug, str(git_root), "skipped", "classification already current")
)
return
if dry_run:
report.add(
RowResult(
slug,
str(git_root),
"updated",
f"would update classification (domain={data.domain}) (dry-run)",
)
)
return
_apply_classification(repo, data, domain_id)
repo.local_path = str(git_root)
host_paths = dict(repo.host_paths or {})
host_paths[hostname] = str(git_root)
repo.host_paths = host_paths
if remote_url:
repo.remote_url = remote_url
if git_fingerprint:
repo.git_fingerprint = git_fingerprint
await session.commit()
report.add(
RowResult(slug, str(git_root), "updated", f"domain={data.domain}")
)
async def _upsert_via_api(
*,
slug: str,
repo_path: Path,
data: ClassificationData,
dry_run: bool,
api_base: str,
report: RegistrationReport,
) -> None:
git_root = _git_root(repo_path)
remote_url = _git_value(git_root, ["remote", "get-url", "origin"])
git_fingerprint = _git_value(git_root, ["rev-list", "--max-parents=0", "HEAD"])
hostname = socket.gethostname()
display_name = git_root.name.replace("-", " ").replace("_", " ").title()
status, existing = _api_request("GET", f"/repos/{slug}", api_base=api_base)
if status == 404 or (isinstance(existing, dict) and existing.get("detail")):
existing = None
elif status == 0:
report.add(
RowResult(
slug,
str(git_root),
"invalid",
f"API unreachable: {existing.get('_error', existing)}",
)
)
return
patch_body = {
"category": data.category,
"secondary_domains": data.secondary_domains,
"capability_tags": data.capability_tags,
"business_stake": data.business_stake,
"business_mechanics": data.business_mechanics,
"classified_at": data.classified_at,
"classified_by": data.classified_by,
"standard_version": data.standard_version,
"domain_slug": data.domain,
"local_path": str(git_root),
"remote_url": remote_url,
"git_fingerprint": git_fingerprint,
}
if existing is None:
if dry_run:
report.add(
RowResult(
slug,
str(git_root),
"registered",
f"would POST /repos/ domain={data.domain} (dry-run)",
)
)
return
post_body = {
"domain_slug": data.domain,
"slug": slug,
"name": display_name,
"local_path": str(git_root),
"host_paths": {hostname: str(git_root)},
"remote_url": remote_url,
"git_fingerprint": git_fingerprint,
}
code, created = _api_request("POST", "/repos/", api_base=api_base, body=post_body)
if code not in (200, 201):
detail = created.get("detail", created) if isinstance(created, dict) else created
report.add(RowResult(slug, str(git_root), "invalid", f"POST failed: {detail}"))
return
code, updated = _api_request(
"PATCH", f"/repos/{slug}", api_base=api_base, body=patch_body
)
if code != 200:
detail = updated.get("detail", updated) if isinstance(updated, dict) else updated
report.add(
RowResult(
slug,
str(git_root),
"invalid",
f"created repo but classification PATCH failed: {detail}",
)
)
return
report.add(RowResult(slug, str(git_root), "registered", f"domain={data.domain}"))
return
if dry_run:
report.add(
RowResult(
slug,
str(git_root),
"updated",
f"would PATCH /repos/{slug} domain={data.domain} (dry-run)",
)
)
return
code, updated = _api_request(
"PATCH", f"/repos/{slug}", api_base=api_base, body=patch_body
)
if code != 200:
detail = updated.get("detail", updated) if isinstance(updated, dict) else updated
report.add(RowResult(slug, str(git_root), "invalid", f"PATCH failed: {detail}"))
return
_api_request(
"POST",
f"/repos/{slug}/paths",
api_base=api_base,
body={"host": hostname, "path": str(git_root)},
)
report.add(RowResult(slug, str(git_root), "updated", f"domain={data.domain}"))
async def register_one(
*,
slug: str,
repo_path: Path,
dry_run: bool = False,
use_api: bool = False,
api_base: str | None = None,
report: RegistrationReport | None = None,
) -> RowResult:
"""Register or update a single repo from its classification file."""
report = report or RegistrationReport()
git_root = _git_root(repo_path)
data, errors, warnings = load_classification_file(git_root)
if data is None:
result = RowResult(
slug,
str(git_root),
"invalid",
"; ".join(errors) or "classification invalid",
warnings=warnings,
)
report.add(result)
return result
if use_api:
await _upsert_via_api(
slug=slug,
repo_path=git_root,
data=data,
dry_run=dry_run,
api_base=api_base or settings.api_base,
report=report,
)
else:
await _upsert_via_db(
slug=slug,
repo_path=git_root,
data=data,
dry_run=dry_run,
report=report,
)
return report.results[-1]
async def _bulk_targets(session) -> list[tuple[str, str]]:
result = await session.execute(
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
)
targets: list[tuple[str, str]] = []
for repo in result.scalars().all():
path = _resolve_repo_path_for_host(repo)
if path:
targets.append((repo.slug, path))
return targets
async def run_registration(args: argparse.Namespace) -> RegistrationReport:
report = RegistrationReport()
use_api = args.api and not args.db
if args.bulk:
async with async_session_factory() as session:
targets = await _bulk_targets(session)
if not targets:
report.add(
RowResult("(bulk)", "", "skipped", "no active repos with accessible local paths")
)
return report
for slug, path in targets:
await register_one(
slug=slug,
repo_path=Path(path),
dry_run=args.dry_run,
use_api=use_api,
api_base=args.api_base,
report=report,
)
return report
if args.repo_path:
repo_path = Path(args.repo_path).expanduser().resolve()
slug = args.slug or _slugify(_git_root(repo_path).name)
await register_one(
slug=slug,
repo_path=repo_path,
dry_run=args.dry_run,
use_api=use_api,
api_base=args.api_base,
report=report,
)
return report
if args.slug:
async with async_session_factory() as session:
repo = await _get_repo_by_slug(session, args.slug)
if repo is None:
report.add(RowResult(args.slug, "", "invalid", "repo slug not found in DB"))
return report
path = _resolve_repo_path_for_host(repo)
if not path:
report.add(
RowResult(
args.slug,
"",
"invalid",
"no accessible local path (local_path / host_paths)",
)
)
return report
await register_one(
slug=args.slug,
repo_path=Path(path),
dry_run=args.dry_run,
use_api=use_api,
api_base=args.api_base,
report=report,
)
return report
raise SystemExit("Specify --repo-path PATH, --slug SLUG, or --bulk")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Register or update managed_repos from .repo-classification.yaml",
)
parser.add_argument("--repo-path", metavar="PATH", help="Local git checkout path")
parser.add_argument(
"--slug",
metavar="SLUG",
help="Registered repo slug (required with --bulk omitted unless --repo-path given)",
)
parser.add_argument(
"--bulk",
action="store_true",
help="All active registered repos with accessible local paths",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Report actions without writing to DB/API",
)
parser.add_argument(
"--api",
action="store_true",
help="Upsert via REST API (default: direct DB session)",
)
parser.add_argument(
"--db",
action="store_true",
help="Force direct DB session (overrides --api)",
)
parser.add_argument(
"--api-base",
default=settings.api_base,
help=f"State Hub API base URL (default: {settings.api_base})",
)
parser.add_argument("--json", action="store_true", help="Emit JSON report")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.bulk:
if args.repo_path:
parser.error("--bulk cannot be combined with --repo-path")
elif args.repo_path:
pass
elif args.slug:
pass
else:
parser.error("Specify one of --repo-path PATH, --slug SLUG, or --bulk")
report = asyncio.run(run_registration(args))
if args.json:
print(json.dumps(report.to_dict(), indent=2))
else:
print(report.render_text())
counts = report.counts()
return 1 if counts["invalid"] else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,334 @@
"""Constants and mappings for STATE-WP-0065 P1 spine migration.
Shared by the Alembic revision and the dry-run report script.
"""
from __future__ import annotations
import uuid
from datetime import date
# Deterministic UUIDs for market-domain rows (stable across runs).
_MARKET_DOMAIN_NAMESPACE = uuid.UUID("8dc7d106-11e2-41df-b512-89ed69d2a65f")
# 14 fixed market domains from Repo Classification Standard v1.0 §6.
MARKET_DOMAINS: list[tuple[str, str]] = [
("infotech", "Infotech"),
("financials", "Financials"),
("communication", "Communication"),
("consumer", "Consumer"),
("health", "Health"),
("industrials", "Industrials"),
("energy", "Energy"),
("utilities", "Utilities"),
("materials", "Materials"),
("realestate", "Real Estate"),
("crypto", "Crypto"),
("agents", "Agents"),
("space", "Space"),
("government", "Government"),
]
# Old coordination-domain slugs (pre-migration ``domains`` table) → market domain.
OLD_DOMAIN_TO_MARKET: dict[str, str] = {
"custodian": "infotech",
"railiance": "financials",
"markitect": "communication",
"coulomb_social": "communication",
"personhood": "government",
"foerster_capabilities": "agents",
# Extended coordination domains (beyond the original 6 canonical seeds).
"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",
}
# Best-effort reverse map for downgrade (lossy: many market domains → one old slug).
MARKET_TO_OLD_DOMAIN: dict[str, str] = {
market: old
for old, market in OLD_DOMAIN_TO_MARKET.items()
}
# Legacy coordination domains restored on downgrade.
OLD_COORDINATION_DOMAINS: list[tuple[str, str]] = [
("custodian", "The Custodian"),
("railiance", "Railiance"),
("markitect", "Markitect"),
("coulomb_social", "Coulomb.social"),
("personhood", "Personhood"),
("foerster_capabilities", "Foerster Capabilities"),
]
# Human-reviewed classifications for the 11 custodian-domain fixture repos.
REPO_CLASSIFICATIONS: dict[str, dict] = {
"the-custodian": {
"category": "research",
"domain": "infotech",
"secondary_domains": ["agents"],
"capability_tags": [
"governance",
"knowledge",
"coordination",
"policy",
"documentation",
],
"business_stake": ["technology", "operations", "intelligence", "execution"],
"business_mechanics": ["intention", "control", "coordination", "adaptation"],
},
"inter-hub": {
"category": "research",
"domain": "infotech",
"secondary_domains": ["agents"],
"capability_tags": [
"governance",
"observability",
"platform",
"coordination",
"orchestration",
],
"business_stake": ["technology", "intelligence", "operations"],
"business_mechanics": ["control", "coordination", "adaptation"],
},
"state-hub": {
"category": "tooling",
"domain": "infotech",
"secondary_domains": ["agents"],
"capability_tags": [
"coordination",
"knowledge",
"platform",
"observability",
"governance",
],
"business_stake": [
"technology",
"operations",
"product",
"intelligence",
"automation",
],
"business_mechanics": ["coordination", "control", "operation", "adaptation"],
},
"hub-core": {
"category": "tooling",
"domain": "infotech",
"secondary_domains": [],
"capability_tags": ["platform", "configuration", "orchestration"],
"business_stake": ["technology", "execution", "product"],
"business_mechanics": ["operation"],
},
"activity-core": {
"category": "tooling",
"domain": "infotech",
"secondary_domains": ["agents"],
"capability_tags": [
"workflow",
"orchestration",
"automation",
"coordination",
"observability",
],
"business_stake": ["technology", "operations", "automation", "execution"],
"business_mechanics": ["coordination", "operation", "adaptation"],
},
"issue-core": {
"category": "tooling",
"domain": "infotech",
"secondary_domains": ["agents"],
"capability_tags": [
"workflow",
"coordination",
"orchestration",
"traceability",
],
"business_stake": ["technology", "product", "operations", "automation"],
"business_mechanics": ["coordination", "operation"],
},
"kaizen-agentic": {
"category": "tooling",
"domain": "agents",
"secondary_domains": ["infotech"],
"capability_tags": [
"orchestration",
"automation",
"coordination",
"knowledge",
"documentation",
],
"business_stake": [
"technology",
"product",
"automation",
"people",
"intelligence",
],
"business_mechanics": [
"intention",
"coordination",
"operation",
"adaptation",
],
},
"llm-connect": {
"category": "tooling",
"domain": "agents",
"secondary_domains": ["infotech"],
"capability_tags": [
"orchestration",
"model-routing",
"configuration",
"automation",
],
"business_stake": ["technology", "product", "automation"],
"business_mechanics": ["operation", "adaptation"],
},
"ops-bridge": {
"category": "tooling",
"domain": "infotech",
"secondary_domains": [],
"capability_tags": [
"operations",
"access-control",
"platform",
"observability",
"orchestration",
],
"business_stake": ["operations", "technology", "automation"],
"business_mechanics": ["control", "operation", "adaptation"],
},
"ops-warden": {
"category": "tooling",
"domain": "infotech",
"secondary_domains": [],
"capability_tags": [
"identity",
"access-control",
"security",
"policy",
"audit",
"governance",
],
"business_stake": ["technology", "operations", "legal", "automation"],
"business_mechanics": ["control", "operation"],
},
"email-connect": {
"category": "tooling",
"domain": "infotech",
"secondary_domains": ["communication"],
"capability_tags": [
"evidence",
"traceability",
"source-management",
"automation",
],
"business_stake": ["technology", "operations", "legal"],
"business_mechanics": ["operation", "coordination"],
},
}
# Repo discrepancy resolution (STATE-WP-0065 §P1 data migration).
REPO_DISPOSITIONS: dict[str, dict] = {
"markitect-project": {
"action": "relink_to",
"target_slug": "markitect-main",
"archive": True,
},
"railiance-bootstrap": {
"action": "archive",
},
"railiance-hosts": {
"action": "archive",
},
"vergabe_teilnahme": {
"action": "collapse_into",
"target_slug": "vergabe-teilnahme",
"archive": True,
},
}
# Fallback repo slug for orphan workplans after backfill.
FALLBACK_REPO_SLUG = "state-hub"
STANDARD_VERSION = "1.0"
def market_domain_uuid(slug: str) -> str:
"""Deterministic UUID string for a market-domain slug."""
return str(uuid.uuid5(_MARKET_DOMAIN_NAMESPACE, f"state-hub.market-domain.{slug}"))
def old_domain_uuid(slug: str) -> str:
"""Deterministic UUID string for a legacy coordination-domain slug."""
return str(uuid.uuid5(_MARKET_DOMAIN_NAMESPACE, f"state-hub.coordination-domain.{slug}"))
def derive_classification(repo_slug: str, old_domain_slug: str | None) -> dict:
"""Return a classification dict for *repo_slug*.
Uses committed ``REPO_CLASSIFICATIONS`` when present; otherwise derives a
migration-time classification from the old coordination domain.
"""
if repo_slug in REPO_CLASSIFICATIONS:
data = dict(REPO_CLASSIFICATIONS[repo_slug])
data.setdefault("classified_by", "human")
return data
market = OLD_DOMAIN_TO_MARKET.get(old_domain_slug or "", "infotech")
# Domain-specific heuristics for repos without committed classification files.
category = "project"
secondary_domains: list[str] = []
capability_tags: list[str] = []
business_stake: list[str] = []
business_mechanics: list[str] = []
if old_domain_slug == "custodian":
category = "tooling"
capability_tags = ["platform"]
business_stake = ["technology", "operations"]
elif old_domain_slug == "railiance":
category = "project"
capability_tags = ["platform", "operations"]
business_stake = ["technology", "operations"]
elif old_domain_slug == "markitect":
category = "project"
capability_tags = ["knowledge", "documentation"]
business_stake = ["technology", "product"]
elif old_domain_slug == "coulomb_social":
category = "experimental"
capability_tags = ["marketplace", "collaboration"]
business_stake = ["product", "sales"]
elif old_domain_slug == "personhood":
category = "research"
capability_tags = ["governance", "policy"]
business_stake = ["legal", "technology", "intelligence"]
business_mechanics = ["intention", "control"]
elif old_domain_slug == "foerster_capabilities":
category = "research"
capability_tags = ["knowledge"]
business_stake = ["intelligence", "technology"]
return {
"category": category,
"domain": market,
"secondary_domains": secondary_domains,
"capability_tags": capability_tags,
"business_stake": business_stake,
"business_mechanics": business_mechanics,
"classified_by": "migration",
}
def migration_provenance() -> dict:
"""Provenance fields applied during Alembic backfill."""
return {
"classified_at": date.today().isoformat(),
"classified_by": "migration",
"standard_version": STANDARD_VERSION,
}

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Dry-run report for STATE-WP-0065 P1 spine migration.
Prints would-be classification, domain, repo-disposition, and workplan-anchor
changes without applying them. Requires a live PostgreSQL connection (same
DATABASE_URL as the API).
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parent.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from sqlalchemy import text # noqa: E402
from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
from api.database import async_session_factory, engine # noqa: E402
from scripts.spine_migration_data import ( # noqa: E402
FALLBACK_REPO_SLUG,
MARKET_DOMAINS,
OLD_DOMAIN_TO_MARKET,
REPO_CLASSIFICATIONS,
REPO_DISPOSITIONS,
derive_classification,
market_domain_uuid,
)
def _section(title: str) -> None:
print()
print("=" * 72)
print(title)
print("=" * 72)
async def _report_domains(session: AsyncSession) -> None:
_section("Domain spine replacement")
result = await session.execute(
text("SELECT slug, name FROM domains ORDER BY slug")
)
current = result.fetchall()
current_slugs = {row[0] for row in current}
print(f"Current domains ({len(current)}):")
for slug, name in current:
mapped = OLD_DOMAIN_TO_MARKET.get(slug, "(no mapping — would delete)")
print(f" {slug:25}{mapped}")
print(f"\nMarket domains to insert ({len(MARKET_DOMAINS)}):")
for slug, name in MARKET_DOMAINS:
flag = "exists" if slug in current_slugs else "NEW"
print(f" [{flag:5}] {slug:20} {name:20} id={market_domain_uuid(slug)}")
async def _report_classifications(session: AsyncSession) -> None:
_section("Repo classification backfill")
rows = await session.execute(
text(
"""
SELECT mr.slug, mr.status, d.slug AS old_domain
FROM managed_repos mr
JOIN domains d ON d.id = mr.domain_id
ORDER BY mr.slug
"""
)
)
from_file = 0
derived = 0
for repo_slug, status, old_domain in rows:
cls = derive_classification(repo_slug, old_domain)
source = "fixture" if repo_slug in REPO_CLASSIFICATIONS else "derived"
if source == "fixture":
from_file += 1
else:
derived += 1
print(
f" {repo_slug:30} [{status:8}] "
f"{old_domain:20}{cls['category']:12} · {cls['domain']:15} "
f"({source}, by={cls.get('classified_by', 'migration')})"
)
print(f"\nSummary: {from_file} from REPO_CLASSIFICATIONS, {derived} derived")
async def _report_dispositions(session: AsyncSession) -> None:
_section("Repo dispositions")
if not REPO_DISPOSITIONS:
print(" (none)")
return
for slug, disp in REPO_DISPOSITIONS.items():
repo = await session.execute(
text("SELECT 1 FROM managed_repos WHERE slug = :slug"),
{"slug": slug},
)
managed = repo.fetchone()
state = "found" if managed else "MISSING"
print(f" {slug:25} [{state}] action={disp['action']}")
if disp.get("target_slug"):
print(f" target: {disp['target_slug']}")
if disp.get("archive"):
print(" would archive phantom/duplicate row")
async def _report_workplan_anchors(session: AsyncSession) -> None:
_section("Workplan repo_id backfill (would-be)")
rows = await session.execute(
text(
"""
SELECT ws.slug, ws.repo_id, t.slug AS topic_slug, d.slug AS domain_slug,
mr.slug AS current_repo
FROM workstreams ws
LEFT JOIN topics t ON t.id = ws.topic_id
LEFT JOIN domains d ON d.id = t.domain_id
LEFT JOIN managed_repos mr ON mr.id = ws.repo_id
ORDER BY ws.slug
"""
)
)
null_count = 0
for ws_slug, repo_id, topic_slug, domain_slug, current_repo in rows:
if repo_id is None:
null_count += 1
print(
f" NEEDS ANCHOR {ws_slug:40} topic={topic_slug or '-':20} "
f"domain={domain_slug or '-'}"
)
else:
print(f" ok {ws_slug:40} repo={current_repo}")
print(f"\nWorkstreams with NULL repo_id: {null_count}")
if null_count:
print(f"Orphans would fall back to: {FALLBACK_REPO_SLUG}")
async def _report_topic_domain_updates(session: AsyncSession) -> None:
_section("Topic / domain_goal domain_id remapping")
for old_slug, market_slug in OLD_DOMAIN_TO_MARKET.items():
topic_count = await session.execute(
text(
"""
SELECT COUNT(*) FROM topics t
JOIN domains d ON d.id = t.domain_id
WHERE d.slug = :old_slug
"""
),
{"old_slug": old_slug},
)
goal_count = await session.execute(
text(
"""
SELECT COUNT(*) FROM domain_goals dg
JOIN domains d ON d.id = dg.domain_id
WHERE d.slug = :old_slug
"""
),
{"old_slug": old_slug},
)
tc = topic_count.scalar_one()
gc = goal_count.scalar_one()
if tc or gc:
print(f" {old_slug:22}{market_slug:15} topics={tc} domain_goals={gc}")
async def _report_table_renames(session: AsyncSession) -> None:
_section("Schema renames (structural)")
fk_tables = [
"tasks.workstream_id",
"decisions.workstream_id",
"progress_events.workstream_id",
"token_events.workstream_id",
"contributions.related_workstream_id",
"extension_points.workstream_id",
"technical_debt.workstream_id",
"capability_requests.requesting_workstream_id",
"capability_requests.fulfilling_workstream_id",
"workplan_launch_requests.workstream_id",
]
for item in fk_tables:
print(f" {item}{item.replace('workstream', 'workplan')}")
print(" workstreams → workplans")
print(" workstream_dependencies → workplan_dependencies")
print(" from_workstream_id → from_workplan_id")
print(" to_workstream_id → to_workplan_id")
async def main() -> None:
print("STATE-WP-0065 P1 — Spine migration dry-run report")
print("(read-only; no changes applied)")
async with async_session_factory() as session:
await _report_domains(session)
await _report_classifications(session)
await _report_dispositions(session)
await _report_workplan_anchors(session)
await _report_topic_domain_updates(session)
await _report_table_renames(session)
await engine.dispose()
print()
print("Dry-run complete. Review the report before running:")
print(" alembic upgrade d8e9f0a1b2c3")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -102,3 +102,62 @@ async def client(test_engine):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Shared entity helpers (workplan-first; legacy workstream names retained)
# ---------------------------------------------------------------------------
async def create_test_domain(client, slug="infotech", name="Infotech"):
r = await client.post("/domains/", json={"slug": slug, "name": name})
assert r.status_code == 201, r.text
return r.json()
async def create_test_topic(client, domain_slug="infotech", slug="testtopic", title="Test Topic"):
r = await client.post("/topics/", json={
"slug": slug, "title": title, "domain": domain_slug,
})
assert r.status_code == 201, r.text
return r.json()
async def create_test_repo(client, domain_slug="infotech", slug="test-repo", **extra):
payload = {
"domain_slug": domain_slug,
"slug": slug,
"name": "Test Repo",
**extra,
}
r = await client.post("/repos/", json=payload)
assert r.status_code == 201, r.text
return r.json()
async def create_test_workplan(
client,
repo_id,
topic_id=None,
slug="test-wp",
title="Test Workplan",
status="active",
**extra,
):
payload = {"repo_id": repo_id, "slug": slug, "title": title, "status": status, **extra}
if topic_id is not None:
payload["topic_id"] = topic_id
r = await client.post("/workplans/", json=payload)
assert r.status_code == 201, r.text
return r.json()
async def create_test_workstream(client, topic_id=None, repo_id=None, slug="test-wp", **kwargs):
"""Legacy helper name — creates a repo-anchored workplan."""
if repo_id is None:
domain = await create_test_domain(client)
if topic_id is None:
topic = await create_test_topic(client, domain_slug=domain["slug"])
topic_id = topic["id"]
repo = await create_test_repo(client, domain_slug=domain["slug"], slug=f"{slug}-repo")
repo_id = repo["id"]
return await create_test_workplan(client, repo_id=repo_id, topic_id=topic_id, slug=slug, **kwargs)

View File

@@ -27,17 +27,19 @@ async def _create_topic(client, domain_slug="testdomain"):
return r.json()
async def _create_workstream(client, topic_id):
r = await client.post("/workstreams/", json={
"topic_id": topic_id, "slug": "test-ws", "title": "Test WS",
})
assert r.status_code == 201, r.text
return r.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_task(client, workstream_id, title="Test task", status="wait"):
async def _create_workstream(client, topic_id, domain_slug="custodian"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug="test-repo")
return await create_test_workplan(
client, repo_id=repo["id"], topic_id=topic_id, slug="test-ws", title="Test WS",
)
async def _create_task(client, workplan_id, title="Test task", status="wait"):
r = await client.post("/tasks/", json={
"workstream_id": workstream_id, "title": title,
"workplan_id": workplan_id, "title": title,
})
assert r.status_code == 201, r.text
task = r.json()

View File

@@ -0,0 +1,67 @@
"""Tests for api.classification validation module (STATE-WP-0065 P2)."""
from __future__ import annotations
import pytest
from api.classification import (
ClassificationData,
validate_classification,
)
def _valid_block(**overrides) -> dict:
base = {
"category": "tooling",
"domain": "infotech",
"secondary_domains": [],
"capability_tags": ["platform"],
"business_stake": ["technology", "operations"],
"business_mechanics": ["coordination"],
}
base.update(overrides)
return base
class TestValidateClassification:
def test_valid_block_passes(self):
errors, warnings = validate_classification(_valid_block())
assert errors == []
def test_missing_category_fails(self):
block = _valid_block()
del block["category"]
errors, _ = validate_classification(block)
assert any("category" in err for err in errors)
def test_invalid_category_fails(self):
errors, _ = validate_classification(_valid_block(category="not-a-category"))
assert any("category" in err for err in errors)
def test_invalid_domain_fails(self):
errors, _ = validate_classification(_valid_block(domain="not-a-domain"))
assert any("domain" in err for err in errors)
def test_unknown_capability_tag_warns(self):
_, warnings = validate_classification(_valid_block(capability_tags=["totally-made-up-tag"]))
assert any("capability_tag" in warn for warn in warnings)
def test_invalid_business_stake_fails(self):
errors, _ = validate_classification(_valid_block(business_stake=["not-a-stake"]))
assert any("business_stake" in err for err in errors)
def test_secondary_domain_repeats_primary_fails(self):
errors, _ = validate_classification(
_valid_block(domain="infotech", secondary_domains=["infotech"])
)
assert any("repeats the primary domain" in err for err in errors)
class TestClassificationData:
def test_round_trip_dict(self):
block = _valid_block(classified_at="2026-06-22", classified_by="human", version="1.0")
data = ClassificationData.from_block(block)
payload = data.to_dict()
assert payload["category"] == "tooling"
assert payload["domain"] == "infotech"
assert payload["classified_by"] == "human"
assert payload["standard_version"] == "1.0"

View File

@@ -23,6 +23,7 @@ import pytest
# Make scripts/ importable without installing
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from api.classification import resolve_topic_domain_slug
from consistency_check import (
ConsistencyReport,
Issue,
@@ -54,6 +55,15 @@ from api.workplan_status import ready_review_status
# for backward compat; their canonical implementations live in repo_sync.py.
# ---------------------------------------------------------------------------
# domain mapping (STATE-WP-0065 P4)
# ---------------------------------------------------------------------------
class TestResolveTopicDomainSlug:
def test_workplan_custodian_maps_to_infotech(self):
assert resolve_topic_domain_slug("custodian", repo_market_domain="infotech") == "infotech"
# ---------------------------------------------------------------------------
# parse_frontmatter
# ---------------------------------------------------------------------------
@@ -372,7 +382,7 @@ class TestRenderText:
r.add(severity="WARN", check_id="C-04", message="w")
r.add(severity="INFO", check_id="C-08", message="i")
text = render_text(r)
assert "1 fail" in text
assert "1 assessment-fail" in text
assert "1 warn" in text
assert "1 info" in text
@@ -443,7 +453,7 @@ class TestReportToDict:
r = ConsistencyReport(repo_slug="r", repo_path="/p")
d = report_to_dict(r)
assert d["result"] == "pass"
assert d["summary"] == {"fail": 0, "warn": 0, "info": 0}
assert d["summary"] == {"fail": 0, "automation_error": 0, "warn": 0, "info": 0}
assert d["issues"] == []
def test_fail_result(self):

View File

@@ -17,8 +17,20 @@ async def _create_topic(client, domain_slug="legacy-domain", slug="legacy-topic"
return r.json()
async def _create_workplan(client, topic_id, slug="legacy-wp", title="Legacy WP"):
async def _create_repo(client, domain_slug="legacy-domain", slug="legacy-repo"):
r = await client.post("/repos/", json={
"domain_slug": domain_slug,
"slug": slug,
"name": "Legacy Repo",
})
assert r.status_code == 201, r.text
return r.json()
async def _create_workplan(client, topic_id, domain_slug="legacy-domain", slug="legacy-wp", title="Legacy WP"):
repo = await _create_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
r = await client.post("/workplans/", json={
"repo_id": repo["id"],
"topic_id": topic_id,
"slug": slug,
"title": title,

View File

@@ -28,12 +28,14 @@ async def _create_topic(client, domain_slug="mcp-domain"):
return r.json()
async def _create_workstream(client, topic_id):
r = await client.post("/workstreams/", json={
"topic_id": topic_id, "slug": "mcp-ws", "title": "MCP Workstream",
})
assert r.status_code == 201
return r.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_workstream(client, topic_id, domain_slug="mcp-domain"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug="mcp-repo")
return await create_test_workplan(
client, repo_id=repo["id"], topic_id=topic_id, slug="mcp-ws", title="MCP Workstream",
)
# ---------------------------------------------------------------------------

View File

@@ -19,15 +19,16 @@ async def _call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
class TestMCPWriteTools:
async def test_create_workstream_returns_rest_shape_and_emits_progress(self, monkeypatch):
async def test_create_workplan_returns_rest_shape_and_emits_progress(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body))
if path == "/workstreams":
if path == "/workplans":
return {
"id": "ws-1",
"topic_id": body["topic_id"],
"id": "wp-1",
"repo_id": body["repo_id"],
"topic_id": body.get("topic_id"),
"title": body["title"],
"slug": body["slug"],
"status": body["status"],
@@ -39,20 +40,42 @@ class TestMCPWriteTools:
monkeypatch.setattr(server, "_post", fake_post)
body = await _call_tool(
"create_workstream",
{"topic_id": "topic-1", "title": "MCP Reliable Write"},
"create_workplan",
{"repo_id": "repo-1", "topic_id": "topic-1", "title": "MCP Reliable Write"},
)
assert body == {
"id": "ws-1",
"id": "wp-1",
"repo_id": "repo-1",
"topic_id": "topic-1",
"title": "MCP Reliable Write",
"slug": "mcp-reliable-write",
"status": "active",
}
assert [path for path, _ in calls] == ["/workstreams", "/progress"]
assert calls[1][1]["workstream_id"] == "ws-1"
assert calls[1][1]["event_type"] == "workstream_created"
assert [path for path, _ in calls] == ["/workplans", "/progress"]
assert calls[1][1]["workplan_id"] == "wp-1"
assert calls[1][1]["event_type"] == "workplan_created"
async def test_create_workstream_legacy_alias_uses_workplans_endpoint(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body))
if path == "/workplans":
return {"id": "wp-1", "repo_id": body["repo_id"], "title": body["title"], "slug": body["slug"], "status": "active"}
if path == "/progress":
return {"id": "event-1", **body}
raise AssertionError(f"unexpected POST {path}")
monkeypatch.setattr(server, "_post", fake_post)
body = await _call_tool(
"create_workstream",
{"repo_id": "repo-1", "title": "Legacy alias"},
)
assert body["id"] == "wp-1"
assert [path for path, _ in calls] == ["/workplans", "/progress"]
async def test_create_task_returns_rest_shape_and_emits_progress(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []
@@ -62,7 +85,8 @@ class TestMCPWriteTools:
if path == "/tasks":
return {
"id": "task-1",
"workstream_id": body["workstream_id"],
"workplan_id": body.get("workplan_id") or body.get("workstream_id"),
"workstream_id": body.get("workplan_id") or body.get("workstream_id"),
"title": body["title"],
"priority": body["priority"],
"status": "todo",
@@ -80,6 +104,7 @@ class TestMCPWriteTools:
assert body == {
"id": "task-1",
"workplan_id": "ws-1",
"workstream_id": "ws-1",
"title": "MCP task",
"priority": "high",
@@ -266,18 +291,18 @@ class TestMCPWriteTools:
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body))
return {"error": "API 422: invalid topic"}
return {"error": "API 422: invalid repo"}
monkeypatch.setattr(server, "_post", fake_post)
body = await _call_tool(
"create_workstream",
{"topic_id": "bad-topic", "title": "No progress on failure"},
{"repo_id": "bad-repo", "title": "No progress on failure"},
)
assert body["tool"] == "create_workstream"
assert body["error"] == "API 422: invalid topic"
assert [path for path, _ in calls] == ["/workstreams"]
assert body["error"] == "API 422: invalid repo"
assert [path for path, _ in calls] == ["/workplans"]
async def test_record_decision_missing_id_is_clear_and_skips_progress(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []

View File

@@ -20,14 +20,18 @@ async def _create_topic(client, domain_slug="digest", slug="digest-topic", title
return response.json()
async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream"):
response = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": title})
assert response.status_code == 201, response.text
return response.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_task(client, workstream_id, title="Digest task"):
response = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title})
async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream", domain_slug="digest"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
return await create_test_workplan(
client, repo_id=repo["id"], topic_id=topic_id, slug=slug, title=title,
)
async def _create_task(client, workplan_id, title="Digest task"):
response = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": title})
assert response.status_code == 201, response.text
return response.json()
@@ -277,12 +281,16 @@ class TestRecentlyOnScopeRoutes:
await _create_domain(client, slug="broken", name="Broken Domain")
broken_topic = await _create_topic(client, domain_slug="broken", slug="broken-topic")
broken_workstream = await _create_workstream(client, broken_topic["id"], slug="broken-ws")
broken_workstream = await _create_workstream(
client, broken_topic["id"], slug="broken-ws", domain_slug="broken",
)
await _create_task(client, broken_workstream["id"], title="Broken source")
await _create_domain(client, slug="good", name="Good Domain")
good_topic = await _create_topic(client, domain_slug="good", slug="good-topic")
good_workstream = await _create_workstream(client, good_topic["id"], slug="good-ws")
good_workstream = await _create_workstream(
client, good_topic["id"], slug="good-ws", domain_slug="good",
)
await _create_task(client, good_workstream["id"], title="Good source")
response = await client.post("/recently-on-scope/hourly", json={"range": "1d"})

View File

@@ -0,0 +1,71 @@
"""Tests for register_from_classification CLI (STATE-WP-0065 P3)."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "scripts" / "register_from_classification.py"
def test_cli_help():
result = subprocess.run(
[sys.executable, str(SCRIPT), "--help"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
assert result.returncode == 0
assert "--repo-path" in result.stdout
assert "--bulk" in result.stdout
assert "--dry-run" in result.stdout
@pytest.mark.asyncio
async def test_dry_run_repo_path_state_hub():
sys.path.insert(0, str(REPO_ROOT))
from scripts.register_from_classification import run_registration
import argparse
args = argparse.Namespace(
repo_path=str(REPO_ROOT),
slug=None,
bulk=False,
dry_run=True,
api=False,
db=False,
api_base="http://127.0.0.1:8000",
json=False,
)
report = await run_registration(args)
counts = report.counts()
assert counts["invalid"] == 0
assert counts["registered"] + counts["updated"] + counts["skipped"] >= 1
assert any(r.slug == "state-hub" for r in report.results)
# Valid classification file is always parsed even when DB domains are absent.
assert not any("repo_classification block" in r.detail for r in report.results)
def test_json_report_shape():
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--repo-path",
str(REPO_ROOT),
"--dry-run",
"--json",
],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
payload = json.loads(result.stdout)
assert payload["summary"]["invalid"] == 0
assert "summary" in payload
assert "results" in payload
assert set(payload["summary"]) == {"registered", "updated", "skipped", "invalid"}

View File

@@ -28,19 +28,34 @@ async def _create_topic(client, domain_slug="testdomain", slug="testtopic", titl
return r.json()
async def _create_workstream(client, topic_id, slug="test-ws", title="Test WS", status="active", **extra):
async def _create_workplan(client, repo_id, topic_id=None, slug="test-ws", title="Test WS", status="active", **extra):
payload = {
"topic_id": topic_id, "slug": slug, "title": title, "status": status,
"repo_id": repo_id, "slug": slug, "title": title, "status": status,
}
if topic_id is not None:
payload["topic_id"] = topic_id
payload.update(extra)
r = await client.post("/workstreams/", json=payload)
r = await client.post("/workplans/", json=payload)
assert r.status_code == 201, r.text
return r.json()
async def _create_task(client, workstream_id, title="Test task"):
async def _create_workstream(client, topic_id=None, repo_id=None, slug="test-ws", title="Test WS", status="active", **extra):
if repo_id is None:
if topic_id is None:
await _create_domain(client)
topic = await _create_topic(client)
topic_id = topic["id"]
repo = await _create_repo(client, slug=f"{slug}-repo")
repo_id = repo["id"]
return await _create_workplan(
client, repo_id=repo_id, topic_id=topic_id, slug=slug, title=title, status=status, **extra
)
async def _create_task(client, workplan_id, title="Test task"):
r = await client.post("/tasks/", json={
"workstream_id": workstream_id, "title": title,
"workplan_id": workplan_id, "title": title,
})
assert r.status_code == 201, r.text
return r.json()

View File

@@ -16,19 +16,20 @@ async def _create_topic(client, domain_slug: str = "bulk-domain"):
return r.json()
async def _create_workstream(client, topic_id: str):
r = await client.post(
"/workstreams/",
json={"topic_id": topic_id, "slug": "bulk-ws", "title": "Bulk Workstream"},
from tests.conftest import create_test_repo, create_test_workplan
async def _create_workstream(client, topic_id: str, domain_slug: str = "bulk-domain"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug="bulk-repo")
return await create_test_workplan(
client, repo_id=repo["id"], topic_id=topic_id, slug="bulk-ws", title="Bulk Workstream",
)
assert r.status_code == 201
return r.json()
async def _create_task(client, workstream_id: str, title: str):
async def _create_task(client, workplan_id: str, title: str):
r = await client.post(
"/tasks/",
json={"workstream_id": workstream_id, "title": title},
json={"workplan_id": workplan_id, "title": title},
)
assert r.status_code == 201
return r.json()

View File

@@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="testdomain"):
return r.json()
async def _create_workstream(client, topic_id, slug="ws1"):
r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": "WS"})
assert r.status_code == 201, r.text
return r.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_task(client, workstream_id):
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": "task"})
async def _create_workstream(client, topic_id, slug="ws1", domain_slug="testdomain"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
return await create_test_workplan(client, repo_id=repo["id"], topic_id=topic_id, slug=slug, title="WS")
async def _create_task(client, workplan_id):
r = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": "task"})
assert r.status_code == 201, r.text
return r.json()

View File

@@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="td"):
return r.json()
async def _create_workstream(client, topic_id):
r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": "ws", "title": "WS"})
assert r.status_code == 201, r.text
return r.json()
from tests.conftest import create_test_repo, create_test_workplan
async def _create_task(client, workstream_id, title="my task"):
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title})
async def _create_workstream(client, topic_id, domain_slug="td"):
repo = await create_test_repo(client, domain_slug=domain_slug, slug="td-repo")
return await create_test_workplan(client, repo_id=repo["id"], topic_id=topic_id, slug="ws", title="WS")
async def _create_task(client, workplan_id, title="my task"):
r = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": title})
assert r.status_code == 201, r.text
return r.json()

View File

@@ -4,11 +4,13 @@ type: workplan
title: "Repo-anchored classification spine (CUST-WP-0050 implementation)"
domain: custodian
repo: state-hub
status: ready
status: finished
owner: custodian
topic_slug: custodian
created: "2026-06-22"
updated: "2026-06-22"
started: "2026-06-22"
finished: "2026-06-22"
state_hub_workstream_id: "8dc7d106-11e2-41df-b512-89ed69d2a65f"
---
@@ -47,7 +49,7 @@ repo-owned files (ADR-001).
```task
id: STATE-WP-0065-T01
status: todo
status: done
priority: high
state_hub_task_id: "14cf65f1-e5af-4905-8de4-bc8986ef078e"
```
@@ -82,7 +84,7 @@ dry-run report is reviewed, and a tested downgrade path exists.
```task
id: STATE-WP-0065-T02
status: todo
status: done
priority: high
state_hub_task_id: "d3afcae1-d47e-42f1-bad8-1de4bd1f126a"
```
@@ -103,7 +105,7 @@ are green.
```task
id: STATE-WP-0065-T03
status: todo
status: done
priority: high
state_hub_task_id: "bab90f0c-238e-4f43-b34c-a8cdd8faf0e6"
```
@@ -122,7 +124,7 @@ emits a report of registered / updated / skipped / invalid.
```task
id: STATE-WP-0065-T04
status: todo
status: done
priority: medium
state_hub_task_id: "67c54009-823d-466b-beaf-f27351c279f4"
```