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,8 +1,15 @@
## Architecture ## Architecture
<!-- TODO: Describe the key design decisions and component structure. State Hub uses a **repo-anchored classification spine** (STATE-WP-0065):
Key modules, data flows, external integrations, state machines, etc. -->
- **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 ## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference `~/state-hub/mcp_server/TOOLS.md` — MCP tool 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. **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 **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** **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 2. **Pending tasks** from `workplans/` + any `[repo:state-hub]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary: 3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"* - `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 4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo 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()` **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). > 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:** **Session close:**
With MCP tools: 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 COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
PYTHON ?= python3 PYTHON ?= python3
@@ -322,5 +322,20 @@ remove-hooks:
gitea-inventory: gitea-inventory:
$(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json) $(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: clean:
$(COMPOSE) down -v $(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` | Start postgres container |
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) | | `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
| `make migrate` | `alembic upgrade head` | | `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 api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` | | `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check | | `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 ## Database Schema
Five tables in dependency order: Repo-anchored coordination spine (STATE-WP-0065):
``` ```
topics domains (14 market domains: infotech, financials, communication, …)
└── workstreams managed_repos (classification: category, domain, capability_tags, business_stake, …)
└── tasks (self-FK: parent_task_id) └── workplans (repo_id required; topic_id optional legacy tag)
└── tasks
└── progress_events └── progress_events
decisions (FK: topic_id, workstream_id — at least one required) topics (optional cross-repo tag; domain_id → market domain)
└── progress_events 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` | | `workplan_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` | | `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
| `task_priority` | `low` · `medium` · `high` · `critical` | | `repo category` | `experimental` · `research` · `project` · `tooling` · `product` · `business` |
| `decision_type` | `made` · `pending` | | `market domain` | 14 fixed slugs — see `the-custodian/canon/standards/repo-classification.allowed.yaml` |
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` |
### Governance constraints encoded in schema ### 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` **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 ## One-Liner
State Hub is the local-first coordination service for Custodian workstreams, State Hub is the local-first coordination service for repo-anchored workplans,
tasks, decisions, progress events, repo metadata, MCP tooling, and dashboard tasks, decisions, progress events, repo classification and metadata, MCP
telemetry. tooling, and dashboard telemetry.
## In Scope ## In Scope
@@ -12,7 +12,8 @@ telemetry.
- PostgreSQL schema and Alembic migrations - PostgreSQL schema and Alembic migrations
- FastMCP server and tool reference - FastMCP server and tool reference
- Observable dashboard - 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 - task-flow engine and flow definitions
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking - SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
- State Hub tests, operational docs, policies, prompts, and local infra - 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.topic import Topic, TopicStatus
from api.models.managed_repo import ManagedRepo from api.models.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal, RepoGoalStatus 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 import Workstream
from api.models.workstream_dependency import WorkstreamDependency from api.models.workstream_dependency import WorkstreamDependency
from api.models.task import Task, TaskStatus, TaskPriority from api.models.task import Task, TaskStatus, TaskPriority
@@ -39,6 +41,8 @@ __all__ = [
"Topic", "TopicStatus", "Topic", "TopicStatus",
"ManagedRepo", "ManagedRepo",
"RepoGoal", "RepoGoalStatus", "RepoGoal", "RepoGoalStatus",
"Workplan",
"WorkplanDependency",
"Workstream", "Workstream",
"WorkstreamDependency", "WorkstreamDependency",
"Task", "TaskStatus", "TaskPriority", "Task", "TaskStatus", "TaskPriority",
@@ -61,4 +65,4 @@ __all__ = [
"WorkplanLaunchRequest", "WorkplanLaunchRequest",
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge", "FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
"LegacyInterface", "LegacyInterfaceUsageBucket", "LegacyInterface", "LegacyInterfaceUsageBucket",
] ]

View File

@@ -31,9 +31,9 @@ class CapabilityRequest(Base, TimestampMixin):
nullable=False, nullable=False,
index=True, index=True,
) )
requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column( requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="SET NULL"), ForeignKey("workplans.id", ondelete="SET NULL"),
nullable=True, nullable=True,
) )
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False) requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
@@ -45,9 +45,9 @@ class CapabilityRequest(Base, TimestampMixin):
nullable=True, nullable=True,
index=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), UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="SET NULL"), ForeignKey("workplans.id", ondelete="SET NULL"),
nullable=True, nullable=True,
) )
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), 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( related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
) )
related_workstream_id: Mapped[uuid.UUID | None] = mapped_column( related_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
) )
repo_id: Mapped[uuid.UUID | None] = mapped_column( repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True 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) notes: Mapped[str | None] = mapped_column(Text, nullable=True)
topic: Mapped["Topic"] = relationship("Topic", 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
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", 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" __tablename__ = "decisions"
__table_args__ = ( __table_args__ = (
CheckConstraint( CheckConstraint(
"topic_id IS NOT NULL OR workstream_id IS NOT NULL", "topic_id IS NOT NULL OR workplan_id IS NOT NULL",
name="ck_decisions_topic_or_workstream", name="ck_decisions_topic_or_workplan",
), ),
) )
@@ -36,8 +36,8 @@ class Decision(Base, TimestampMixin):
topic_id: Mapped[uuid.UUID | None] = mapped_column( topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
) )
workstream_id: Mapped[uuid.UUID | None] = mapped_column( workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
) )
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) 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 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 progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="decision", lazy="selectin" "ProgressEvent", back_populates="decision", lazy="selectin"
) )

View File

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

View File

@@ -1,8 +1,8 @@
import uuid import uuid
from datetime import datetime from datetime import date, datetime
from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy import Date, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid from api.models.base import Base, TimestampMixin, new_uuid
@@ -36,6 +36,15 @@ class ManagedRepo(Base, TimestampMixin):
DateTime(timezone=True), nullable=True 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: Mapped["Domain"] = relationship( # noqa: F821
"Domain", back_populates="repos", lazy="selectin" "Domain", back_populates="repos", lazy="selectin"
) )

View File

@@ -19,8 +19,8 @@ class ProgressEvent(Base):
topic_id: Mapped[uuid.UUID | None] = mapped_column( topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
) )
workstream_id: Mapped[uuid.UUID | None] = mapped_column( workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
) )
task_id: Mapped[uuid.UUID | None] = mapped_column( task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True 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 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 task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
decision: Mapped["Decision | None"] = relationship("Decision", 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 domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
"DomainGoal", back_populates="repo_goals", lazy="selectin" "DomainGoal", back_populates="repo_goals", lazy="selectin"
) )
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821 workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
"Workstream", back_populates="repo_goal", lazy="selectin" "Workplan", back_populates="repo_goal", lazy="selectin"
) )
@property @property

View File

@@ -30,8 +30,8 @@ class Task(Base, TimestampMixin):
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid 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="RESTRICT"), nullable=False, index=True UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True
) )
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) 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 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( subtasks: Mapped[list["Task"]] = relationship(
"Task", foreign_keys=[parent_task_id], lazy="selectin" "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( topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
) )
workstream_id: Mapped[uuid.UUID | None] = mapped_column( workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
) )
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
topic: Mapped["Topic"] = relationship("Topic", 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( notes: Mapped[list["TDNote"]] = relationship(
"TDNote", back_populates="td", lazy="selectin", "TDNote", back_populates="td", lazy="selectin",
order_by="TDNote.created_at", order_by="TDNote.created_at",

View File

@@ -27,8 +27,8 @@ class TokenEvent(Base):
task_id: Mapped[uuid.UUID | None] = mapped_column( task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
) )
workstream_id: Mapped[uuid.UUID | None] = mapped_column( workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True, index=True UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, index=True
) )
repo_id: Mapped[uuid.UUID | None] = mapped_column( repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True 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 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 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: Mapped["Domain"] = relationship( # noqa: F821
"Domain", back_populates="topics", lazy="selectin" "Domain", back_populates="topics", lazy="selectin"
) )
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821 workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
"Workstream", back_populates="topic", lazy="selectin" "Workplan", back_populates="topic", lazy="selectin"
) )
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821 decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
"Decision", back_populates="topic", lazy="selectin" "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( id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid 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), UUID(as_uuid=True),
ForeignKey("workstreams.id", ondelete="CASCADE"), ForeignKey("workplans.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True, index=True,
) )
@@ -36,4 +36,4 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
notes: Mapped[str | None] = mapped_column(Text, nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True)
request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") 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 """Backward-compatibility shim — prefer ``api.models.workplan``."""
from datetime import date, datetime from api.models.workplan import Workplan
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text Workstream = Workplan
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid __all__ = ["Workstream", "Workplan"]
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"
)

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 WorkstreamDependency = WorkplanDependency
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid __all__ = ["WorkstreamDependency", "WorkplanDependency"]
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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ def _needs_escalation(body: DecisionCreate) -> str | None:
@router.get("/", response_model=list[DecisionRead]) @router.get("/", response_model=list[DecisionRead])
async def list_decisions( async def list_decisions(
topic_id: uuid.UUID | None = None, topic_id: uuid.UUID | None = None,
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None,
status: DecisionStatus | None = None, status: DecisionStatus | None = None,
decision_type: DecisionType | None = None, decision_type: DecisionType | None = None,
@@ -48,8 +49,9 @@ async def list_decisions(
q = select(Decision) q = select(Decision)
if topic_id: if topic_id:
q = q.where(Decision.topic_id == topic_id) q = q.where(Decision.topic_id == topic_id)
if workstream_id: scope_id = workplan_id or workstream_id
q = q.where(Decision.workstream_id == workstream_id) if scope_id:
q = q.where(Decision.workplan_id == scope_id)
if status: if status:
q = q.where(Decision.status == status) q = q.where(Decision.status == status)
if decision_type: if decision_type:
@@ -139,7 +141,7 @@ async def resolve_decision_action(
event = ProgressEvent( event = ProgressEvent(
topic_id=decision.topic_id, topic_id=decision.topic_id,
workstream_id=decision.workstream_id, workplan_id=decision.workplan_id,
decision_id=decision.id, decision_id=decision.id,
event_type="decision_resolved", event_type="decision_resolved",
summary=f"Decision resolved: {decision.title}", summary=f"Decision resolved: {decision.title}",
@@ -159,7 +161,7 @@ async def resolve_decision_action(
"decision_id": str(decision.id), "decision_id": str(decision.id),
"title": decision.title, "title": decision.title,
"topic_id": str(decision.topic_id) if decision.topic_id else None, "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, "decided_by": body.decided_by,
"rationale_snippet": (body.rationale or "")[:240], "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.managed_repo import ManagedRepo
from api.models.technical_debt import TechnicalDebt from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic from api.models.topic import Topic
from api.models.workstream import Workstream from api.models.workplan import Workplan
from api.schemas.domain import ( from api.schemas.domain import (
DomainCreate, DomainCreate,
DomainDetail, DomainDetail,
@@ -32,9 +32,9 @@ async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainD
workstream_count = 0 workstream_count = 0
if topic_ids: if topic_ids:
workstream_count_row = await session.execute( workstream_count_row = await session.execute(
select(func.count()).select_from(Workstream) select(func.count()).select_from(Workplan)
.where(Workstream.topic_id.in_(topic_ids)) .where(Workplan.topic_id.in_(topic_ids))
.where(Workstream.status == "active") .where(Workplan.status == "active")
) )
workstream_count = workstream_count_row.scalar_one() 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.database import get_session
from api.models.task import Task, TaskStatus from api.models.task import Task, TaskStatus
from api.models.workplan_launch_request import WorkplanLaunchRequest from api.models.workplan_launch_request import WorkplanLaunchRequest
from api.models.workstream import Workstream from api.models.workplan import Workplan
from api.models.workstream_dependency import WorkstreamDependency from api.models.workplan_dependency import WorkplanDependency
from api.schemas.execution import ( from api.schemas.execution import (
ExecutionIntentRead, ExecutionIntentRead,
ExecutionIntentUpdate, ExecutionIntentUpdate,
@@ -25,10 +25,10 @@ from api.services.execution_queue import (
STATE_HUB_RESPONSIBILITIES, STATE_HUB_RESPONSIBILITIES,
execution_state_for_launch, execution_state_for_launch,
queue_sort_key, queue_sort_key,
workstream_blockers, workplan_blockers,
) )
from api.routers.workstreams import _legacy_key, _meter_legacy_route 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"]) router = APIRouter(prefix="/execution", tags=["execution"])
@@ -50,7 +50,7 @@ async def _update_execution_intent(
body: ExecutionIntentUpdate, body: ExecutionIntentUpdate,
session: AsyncSession, session: AsyncSession,
) -> ExecutionIntentRead: ) -> ExecutionIntentRead:
ws = await session.get(Workstream, workstream_id) ws = await session.get(Workplan, workstream_id)
if ws is None: if ws is None:
raise HTTPException(status_code=404, detail="Workplan not found") raise HTTPException(status_code=404, detail="Workplan not found")
@@ -94,22 +94,22 @@ async def workplan_stack(
include_blocked: bool = Query(True), include_blocked: bool = Query(True),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[WorkplanQueueItem]: ) -> list[WorkplanQueueItem]:
result = await session.execute(select(Workstream)) result = await session.execute(select(Workplan))
workstreams = [ workstreams = [
ws for ws in result.scalars().all() 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_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]] = {} ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {} task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
for dep in dep_result.scalars().all(): for dep in dep_result.scalars().all():
if dep.to_workstream_id is not None: if dep.to_workplan_id is not None:
ws_deps.setdefault(dep.from_workstream_id, []).append(dep.to_workstream_id) ws_deps.setdefault(dep.from_workplan_id, []).append(dep.to_workplan_id)
if dep.to_task_id is not None: 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_ids = [task_id for ids in task_deps.values() for task_id in ids]
task_status: dict[uuid.UUID, str] = {} task_status: dict[uuid.UUID, str] = {}
@@ -121,9 +121,9 @@ async def workplan_stack(
for ws in workstreams: for ws in workstreams:
if not include_manual and ws.execution_state == "manual": if not include_manual and ws.execution_state == "manual":
continue continue
lifecycle_status = normalize_workstream_status(ws.status) lifecycle_status = normalize_workplan_status(ws.status)
blocked_ws = [ 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 if blocker in ws_by_id or blocker in ws_status
] ]
blocked_tasks = [ blocked_tasks = [
@@ -135,7 +135,7 @@ async def workplan_stack(
continue continue
sort_key = queue_sort_key(ws, eligible=eligible) sort_key = queue_sort_key(ws, eligible=eligible)
items.append(WorkplanQueueItem( items.append(WorkplanQueueItem(
workstream_id=ws.id, workplan_id=ws.id,
slug=ws.slug, slug=ws.slug,
title=ws.title, title=ws.title,
status=lifecycle_status, status=lifecycle_status,
@@ -149,7 +149,7 @@ async def workplan_stack(
execution_group=ws.execution_group, execution_group=ws.execution_group,
scheduled_for=ws.scheduled_for, scheduled_for=ws.scheduled_for,
eligible=eligible, eligible=eligible,
blocked_by_workstream_ids=blocked_ws, blocked_by_workplan_ids=blocked_ws,
blocked_by_task_ids=blocked_tasks, blocked_by_task_ids=blocked_tasks,
sort_key=sort_key, sort_key=sort_key,
)) ))
@@ -165,12 +165,12 @@ async def create_launch_request(
body: LaunchRequestCreate, body: LaunchRequestCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> WorkplanLaunchRequest: ) -> WorkplanLaunchRequest:
ws = await session.get(Workstream, body.workstream_id) ws = await session.get(Workplan, body.workplan_id)
if ws is None: 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( launch_request = WorkplanLaunchRequest(
workstream_id=ws.id, workplan_id=ws.id,
requested_by=body.requested_by, requested_by=body.requested_by,
requested_actor=body.requested_actor, requested_actor=body.requested_actor,
launch_mode=body.launch_mode, launch_mode=body.launch_mode,
@@ -199,16 +199,16 @@ async def list_launch_requests(
) -> list[WorkplanLaunchRequest]: ) -> list[WorkplanLaunchRequest]:
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc()) q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
if workstream_id: if workstream_id:
q = q.where(WorkplanLaunchRequest.workstream_id == workstream_id) q = q.where(WorkplanLaunchRequest.workplan_id == workstream_id)
if request_status: if request_status:
q = q.where(WorkplanLaunchRequest.status == request_status) q = q.where(WorkplanLaunchRequest.status == request_status)
result = await session.execute(q) result = await session.execute(q)
return list(result.scalars().all()) return list(result.scalars().all())
def _intent_read(ws: Workstream) -> ExecutionIntentRead: def _intent_read(ws: Workplan) -> ExecutionIntentRead:
return ExecutionIntentRead( return ExecutionIntentRead(
workstream_id=ws.id, workplan_id=ws.id,
execution_state=ws.execution_state, execution_state=ws.execution_state,
launch_mode=ws.launch_mode, launch_mode=ws.launch_mode,
concurrency_mode=ws.concurrency_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.capability_request import CapabilityRequest
from api.models.contribution import Contribution from api.models.contribution import Contribution
from api.models.task import Task, TaskStatus from api.models.task import Task, TaskStatus
from api.models.workstream import Workstream from api.models.workplan import Workplan
from api.models.workstream_dependency import WorkstreamDependency from api.models.workplan_dependency import WorkplanDependency
from api.services.lifecycle import transition_task_status, transition_workstream_status from api.services.lifecycle import transition_task_status, transition_workplan_status
from api.workplan_status import normalize_workstream_status from api.workplan_status import normalize_workplan_status
router = APIRouter(prefix="/flows", tags=["flows"]) router = APIRouter(prefix="/flows", tags=["flows"])
@@ -94,9 +94,9 @@ async def advance_workstation(
entity = await _entity(entity_type, entity_id, session) entity = await _entity(entity_type, entity_id, session)
if entity_type == "workstream": if entity_type == "workstream":
transition_workstream_status(entity, target_workstation) transition_workplan_status(entity, target_workstation)
elif entity_type == "task": elif entity_type == "task":
parent = await session.get(Workstream, entity.workstream_id) parent = await session.get(Workplan, entity.workplan_id)
transition_task_status( transition_task_status(
entity, entity,
target_workstation, target_workstation,
@@ -117,7 +117,7 @@ async def _flow_object(
) -> dict[str, Any]: ) -> dict[str, Any]:
entity = await _entity(entity_type, entity_id, session) entity = await _entity(entity_type, entity_id, session)
status = _value(entity.status) 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] = { obj: dict[str, Any] = {
"id": str(entity.id), "id": str(entity.id),
"status": current_status, "status": current_status,
@@ -127,21 +127,21 @@ async def _flow_object(
if entity_type == "workstream": if entity_type == "workstream":
tasks = list((await session.execute( tasks = list((await session.execute(
select(Task).where(Task.workstream_id == entity_id) select(Task).where(Task.workplan_id == entity_id)
)).scalars().all()) )).scalars().all())
deps = list((await session.execute( deps = list((await session.execute(
select(WorkstreamDependency).where( select(WorkplanDependency).where(
WorkstreamDependency.from_workstream_id == entity_id WorkplanDependency.from_workplan_id == entity_id
) )
)).scalars().all()) )).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]] = [] dependency_workstations: list[dict[str, Any]] = []
if dependency_ids: if dependency_ids:
dep_ws = list((await session.execute( dep_ws = list((await session.execute(
select(Workstream).where(Workstream.id.in_(dependency_ids)) select(Workplan).where(Workplan.id.in_(dependency_ids))
)).scalars().all()) )).scalars().all())
dependency_workstations = [ 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 for ws in dep_ws
] ]
obj.update({ obj.update({
@@ -163,7 +163,7 @@ async def _entity(
session: AsyncSession, session: AsyncSession,
): ):
model_by_type = { model_by_type = {
"workstream": Workstream, "workstream": Workplan,
"task": Task, "task": Task,
"contribution": Contribution, "contribution": Contribution,
"capability_request": CapabilityRequest, "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.managed_repo import ManagedRepo
from api.models.task import Task from api.models.task import Task
from api.models.task import TaskStatus 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.schemas.reconciliation import StateChangeRequest, StateChangeResponse
from api.services.lifecycle import ( from api.services.lifecycle import (
should_activate_parent_for_task_start, should_activate_parent_for_task_start,
status_value, status_value,
transition_task_status, transition_task_status,
transition_workstream_status, transition_workplan_status,
) )
from api.task_status import TERMINAL_TASK_STATUSES from api.task_status import TERMINAL_TASK_STATUSES
from api.services.reconciliation import ( from api.services.reconciliation import (
ReconciliationClass, ReconciliationClass,
StateChangeClassification, StateChangeClassification,
classify_task_status_change, classify_task_status_change,
classify_workstream_status_change, classify_workplan_status_change,
) )
from api.services.workplan_files import ( from api.services.workplan_files import (
find_workplan_for_workstream, find_workplan_for_workplan,
patch_task_status, patch_task_status,
patch_workplan_status, patch_workplan_status,
resolve_repo_path, resolve_repo_path,
@@ -33,7 +33,7 @@ from api.services.workplan_files import (
task_block_linked, task_block_linked,
workplan_status, workplan_status,
) )
from api.workplan_status import normalize_workstream_status from api.workplan_status import normalize_workplan_status
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"]) 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: 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()] statuses = [status_value(row[0]) for row in result.all()]
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses) 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), session: AsyncSession = Depends(get_session),
) -> StateChangeResponse: ) -> StateChangeResponse:
if body.target_type == "workstream": 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: 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 = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
repo_path = resolve_repo_path(repo) 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_file_backed = workplan_ref is not None
actual_archived_file = bool(workplan_ref and workplan_ref.archived) actual_archived_file = bool(workplan_ref and workplan_ref.archived)
file_backed = ( file_backed = (
@@ -122,9 +122,9 @@ async def classify_state_change(
if body.tasks_terminal is not None if body.tasks_terminal is not None
else await _workstream_tasks_terminal(session, ws.id) else await _workstream_tasks_terminal(session, ws.id)
) )
current_status = normalize_workstream_status(ws.status) current_status = normalize_workplan_status(ws.status)
target_status = normalize_workstream_status(body.target_status) target_status = normalize_workplan_status(body.target_status)
classification = classify_workstream_status_change( classification = classify_workplan_status_change(
current_status=current_status, current_status=current_status,
target_status=target_status, target_status=target_status,
file_backed=file_backed, file_backed=file_backed,
@@ -136,7 +136,7 @@ async def classify_state_change(
conflict = False conflict = False
if body.apply: if body.apply:
expected_status = ( expected_status = (
normalize_workstream_status(body.expected_current_status) normalize_workplan_status(body.expected_current_status)
if body.expected_current_status is not None if body.expected_current_status is not None
else None else None
) )
@@ -153,7 +153,7 @@ async def classify_state_change(
) )
conflict = True conflict = True
elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref: 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: if file_status and file_status != current_status:
classification = _conflict( classification = _conflict(
f"workplan file status {file_status!r} differs from cached DB status {current_status!r}", 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: else:
try: try:
patch_workplan_status(workplan_ref.path, target_status) 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: except OSError as exc:
classification = _conflict( classification = _conflict(
f"workplan file write failed: {exc}", f"workplan file write failed: {exc}",
@@ -178,7 +178,7 @@ async def classify_state_change(
) )
conflict = True conflict = True
else: else:
transition_workstream_status(ws, target_status) transition_workplan_status(ws, target_status)
await session.commit() await session.commit()
write_result = "applied" write_result = "applied"
@@ -221,10 +221,10 @@ async def classify_state_change(
if task is None: if task is None:
raise HTTPException(status_code=404, detail="Task not found") 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 = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
repo_path = resolve_repo_path(repo) 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_file_backed = workplan_ref is not None
actual_archived_file = bool(workplan_ref and workplan_ref.archived) actual_archived_file = bool(workplan_ref and workplan_ref.archived)
file_backed = ( file_backed = (
@@ -291,7 +291,7 @@ async def classify_state_change(
parent_will_activate = should_activate_parent_for_task_start( parent_will_activate = should_activate_parent_for_task_start(
previous_task_status=current_status, previous_task_status=current_status,
new_task_status=target_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: try:
original_text = workplan_ref.path.read_text(encoding="utf-8") 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)) patched_status = status_value(task_block_status(workplan_ref.path, task.id))
if parent_will_activate: if parent_will_activate:
patch_workplan_status(workplan_ref.path, "active") 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 parent_status != "active":
if original_text is not None: if original_text is not None:
workplan_ref.path.write_text(original_text, encoding="utf-8") workplan_ref.path.write_text(original_text, encoding="utf-8")

View File

@@ -9,9 +9,10 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import case, func, select from sqlalchemy import case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from api.config import settings from api.config import settings
from api.database import get_session 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.repo_goal import RepoGoal
from api.models.tpsc import TPSCSnapshot from api.models.tpsc import TPSCSnapshot
from api.models.task import Task 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.doi import DoICriterion, DoIReport, DoISummaryEntry
from api.schemas.managed_repo import ( from api.schemas.managed_repo import (
DispatchTask, DispatchTask,
DispatchWorkstream, DispatchWorkplan,
PendingInterfaceChange, PendingInterfaceChange,
RepoCreate, RepoCreate,
RepoDispatch, RepoDispatch,
@@ -44,6 +45,8 @@ from api.schemas.managed_repo import (
RepoScopeHealth, RepoScopeHealth,
RepoUpdate, RepoUpdate,
ScopeIssueDetail, ScopeIssueDetail,
classification_fields_set,
validate_repo_classification_fields,
) )
from hub_core.routers.repos import create_repos_router 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_read_schema=RepoRead,
repo_path_register_schema=RepoPathRegister, repo_path_register_schema=RepoPathRegister,
list_noload_fields=("goals",), 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, after_register=_publish_repo_registered,
**route_flags, **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) @router.post("/onboard", response_model=RepoOnboardResult)
@@ -428,6 +525,38 @@ async def list_repo_scope_health(
return entries 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( router.include_router(
_core_repo_router( _core_repo_router(
include_collection_routes=False, include_collection_routes=False,
@@ -480,19 +609,19 @@ async def get_repo_dispatch(
# Active workstreams # Active workstreams
ws_result = await session.execute( ws_result = await session.execute(
select(Workstream) select(Workplan)
.where(Workstream.repo_id == repo.id, Workstream.status == "active") .where(Workplan.repo_id == repo.id, Workplan.status == "active")
.order_by(Workstream.created_at) .order_by(Workplan.created_at)
) )
workstreams = list(ws_result.scalars().all()) workstreams = list(ws_result.scalars().all())
dispatch_workstreams: list[DispatchWorkstream] = [] dispatch_workstreams: list[DispatchWorkplan] = []
all_interventions: list[DispatchTask] = [] all_interventions: list[DispatchTask] = []
for ws in workstreams: for ws in workstreams:
task_result = await session.execute( task_result = await session.execute(
select(Task) 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) .order_by(Task.created_at)
) )
tasks = list(task_result.scalars().all()) tasks = list(task_result.scalars().all())
@@ -511,7 +640,7 @@ async def get_repo_dispatch(
all_interventions.extend(interventions) all_interventions.extend(interventions)
dispatch_workstreams.append( dispatch_workstreams.append(
DispatchWorkstream( DispatchWorkplan(
id=ws.id, id=ws.id,
title=ws.title, title=ws.title,
status=ws.status, status=ws.status,
@@ -554,7 +683,7 @@ async def get_repo_dispatch(
return RepoDispatch( return RepoDispatch(
repo_slug=slug, repo_slug=slug,
active_goal=active_goal, active_goal=active_goal,
active_workstreams=dispatch_workstreams, active_workplans=dispatch_workstreams,
human_interventions=all_interventions, human_interventions=all_interventions,
pending_interface_changes=pending_changes, pending_interface_changes=pending_changes,
scope_needs_review=scope_needs_review, 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.task import Task, TaskPriority, TaskStatus
from api.models.technical_debt import TechnicalDebt from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic, TopicStatus from api.models.topic import Topic, TopicStatus
from api.models.workstream import Workstream from api.models.workplan import Workplan
from api.models.workstream_dependency import WorkstreamDependency from api.models.workplan_dependency import WorkplanDependency
from api.schemas.decision import DecisionRead from api.schemas.decision import DecisionRead
from api.schemas.domain import DomainSummary from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead 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.routers.workstreams import _workplan_index
from api.task_status import TERMINAL_TASK_STATUSES, status_value from api.task_status import TERMINAL_TASK_STATUSES, status_value
from api.workplan_status import ( from api.workplan_status import (
CLOSED_WORKSTREAM_STATUSES, CLOSED_WORKPLAN_STATUSES,
OPEN_WORKSTREAM_STATUSES, OPEN_WORKPLAN_STATUSES,
normalize_workstream_status, normalize_workplan_status,
) )
from task_flow_engine import FlowEngine from task_flow_engine import FlowEngine
@@ -82,7 +82,7 @@ async def get_summary(
select(Topic) select(Topic)
.options( .options(
selectinload(Topic.domain), selectinload(Topic.domain),
noload(Topic.workstreams), noload(Topic.workplans),
noload(Topic.decisions), noload(Topic.decisions),
noload(Topic.progress_events), noload(Topic.progress_events),
) )
@@ -96,16 +96,16 @@ async def get_summary(
if topic_ids: if topic_ids:
topic_ws_rows = await session.execute( topic_ws_rows = await session.execute(
select( select(
Workstream.topic_id, Workplan.topic_id,
Workstream.id, Workplan.id,
Workstream.slug, Workplan.slug,
Workstream.title, Workplan.title,
Workstream.status, Workplan.status,
Workstream.owner, Workplan.owner,
Workstream.due_date, Workplan.due_date,
) )
.where(Workstream.topic_id.in_(topic_ids)) .where(Workplan.topic_id.in_(topic_ids))
.order_by(Workstream.created_at) .order_by(Workplan.created_at)
) )
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows: for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
topic_workstreams.setdefault(topic_id, []).append({ topic_workstreams.setdefault(topic_id, []).append({
@@ -136,10 +136,10 @@ async def get_summary(
recent = list(recent_rows.scalars().all()) recent = list(recent_rows.scalars().all())
open_ws_rows = await session.execute( open_ws_rows = await session.execute(
select(Workstream) select(Workplan)
.options(noload("*")) .options(noload("*"))
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES)) .where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at) .order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
) )
open_ws = list(open_ws_rows.scalars().all()) open_ws = list(open_ws_rows.scalars().all())
@@ -147,7 +147,7 @@ async def get_summary(
task_per_ws: dict = {} task_per_ws: dict = {}
task_statuses_per_ws: dict = {} task_statuses_per_ws: dict = {}
for ws_id, tstat, cnt in await session.execute( 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_per_ws.setdefault(ws_id, {})[tstat] = cnt
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt) task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
@@ -157,9 +157,9 @@ async def get_summary(
dep_rows = [] dep_rows = []
if open_ws_ids: if open_ws_ids:
dep_result = await session.execute( dep_result = await session.execute(
select(WorkstreamDependency).where( select(WorkplanDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids)) (WorkplanDependency.from_workplan_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids)) | (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
) )
) )
dep_rows = list(dep_result.scalars().all()) dep_rows = list(dep_result.scalars().all())
@@ -168,16 +168,16 @@ async def get_summary(
dep_ws_ids = set() dep_ws_ids = set()
dep_task_ids = set() dep_task_ids = set()
for d in dep_rows: for d in dep_rows:
dep_ws_ids.add(d.from_workstream_id) dep_ws_ids.add(d.from_workplan_id)
if d.to_workstream_id: if d.to_workplan_id:
dep_ws_ids.add(d.to_workstream_id) dep_ws_ids.add(d.to_workplan_id)
if d.to_task_id: if d.to_task_id:
dep_task_ids.add(d.to_task_id) dep_task_ids.add(d.to_task_id)
ws_lookup: dict = {w.id: w for w in open_ws} ws_lookup: dict = {w.id: w for w in open_ws}
extra_ids = dep_ws_ids - set(ws_lookup.keys()) extra_ids = dep_ws_ids - set(ws_lookup.keys())
if extra_ids: if extra_ids:
extra_rows = await session.execute( 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(): for w in extra_rows.scalars():
ws_lookup[w.id] = w ws_lookup[w.id] = w
@@ -189,7 +189,7 @@ async def get_summary(
# Index: workstream_id → (depends_on stubs, blocks stubs) # Index: workstream_id → (depends_on stubs, blocks stubs)
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
for d in dep_rows: 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: if from_id in dep_index and to_id and to_id in ws_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub( dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id, dep_id=d.id,
@@ -230,9 +230,9 @@ async def get_summary(
"workstation": w.status, "workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])], "tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"dependencies": [ "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 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) 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) select(Topic.status, func.count()).group_by(Topic.status)
)} )}
ws_counts = {r[0]: r[1] for r in await session.execute( 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( task_counts = {r[0]: r[1] for r in await session.execute(
select(Task.status, func.count()).group_by(Task.status) select(Task.status, func.count()).group_by(Task.status)
@@ -407,7 +407,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
select(Topic) select(Topic)
.options( .options(
selectinload(Topic.domain), selectinload(Topic.domain),
noload(Topic.workstreams), noload(Topic.workplans),
noload(Topic.decisions), noload(Topic.decisions),
noload(Topic.progress_events), 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} topic_map = {topic.id: topic for topic in topics}
workstream_rows = await session.execute( workstream_rows = await session.execute(
select(Workstream) select(Workplan)
.options(noload("*")) .options(noload("*"))
.order_by( .order_by(
Workstream.planning_priority.asc().nullslast(), Workplan.planning_priority.asc().nullslast(),
Workstream.planning_order.asc().nullslast(), Workplan.planning_order.asc().nullslast(),
Workstream.updated_at.desc(), Workplan.updated_at.desc(),
) )
) )
workstreams_all = list(workstream_rows.scalars().all()) 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_statuses_per_ws: dict = {}
task_totals_by_status: dict[str, int] = {} task_totals_by_status: dict[str, int] = {}
for ws_id, task_status, count in await session.execute( 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) status = status_value(task_status)
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0}) 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 = [ open_ws = [
w for w in workstreams_all 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] open_ws_ids = [w.id for w in open_ws]
dep_rows = [] dep_rows = []
if open_ws_ids: if open_ws_ids:
dep_result = await session.execute( dep_result = await session.execute(
select(WorkstreamDependency).where( select(WorkplanDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids)) (WorkplanDependency.from_workplan_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids)) | (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
) )
) )
dep_rows = list(dep_result.scalars().all()) dep_rows = list(dep_result.scalars().all())
@@ -490,19 +490,19 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
"workstation": w.status, "workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])], "tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"dependencies": [ "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 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) 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( topic_counts = {r[0]: r[1] for r in await session.execute(
select(Topic.status, func.count()).group_by(Topic.status) select(Topic.status, func.count()).group_by(Topic.status)
)} )}
ws_counts = {r[0]: r[1] for r in await session.execute( 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( dec_counts = {r[0]: r[1] for r in await session.execute(
select(Decision.status, func.count()).group_by(Decision.status) 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( workplan_rows.append(DashboardWorkplanRow(
id=w.id, id=w.id,
title=w.title, 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"), 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"), repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
workplan_filename=workplan.get("filename"), 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) # Active workstream counts per domain (join through topics)
ws_per_domain = {} ws_per_domain = {}
for domain_id, cnt in await session.execute( for domain_id, cnt in await session.execute(
select(Topic.domain_id, func.count(Workstream.id)) select(Topic.domain_id, func.count(Workplan.id))
.join(Workstream, Workstream.topic_id == Topic.id) .join(Workplan, Workplan.topic_id == Topic.id)
.where(Workstream.status.in_(["active", "blocked"])) .where(Workplan.status.in_(["active", "blocked"]))
.group_by(Topic.domain_id) .group_by(Topic.domain_id)
): ):
ws_per_domain[domain_id] = cnt 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. Used by workstreams.md and dependencies.md which only need dep edges.
""" """
open_ws_rows = await session.execute( open_ws_rows = await session.execute(
select(Workstream) select(Workplan)
.options(noload("*")) .options(noload("*"))
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES)) .where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at) .order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
) )
open_ws = list(open_ws_rows.scalars().all()) 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 = [] dep_rows = []
if open_ws_ids: if open_ws_ids:
dep_result = await session.execute( dep_result = await session.execute(
select(WorkstreamDependency).where( select(WorkplanDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids)) (WorkplanDependency.from_workplan_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids)) | (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
) )
) )
dep_rows = list(dep_result.scalars().all()) 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_ws_ids: set = set()
dep_task_ids: set = set() dep_task_ids: set = set()
for d in dep_rows: for d in dep_rows:
dep_ws_ids.add(d.from_workstream_id) dep_ws_ids.add(d.from_workplan_id)
if d.to_workstream_id: if d.to_workplan_id:
dep_ws_ids.add(d.to_workstream_id) dep_ws_ids.add(d.to_workplan_id)
if d.to_task_id: if d.to_task_id:
dep_task_ids.add(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()) extra_ids = dep_ws_ids - set(ws_lookup.keys())
if extra_ids: if extra_ids:
extra_rows = await session.execute( 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(): for w in extra_rows.scalars():
ws_lookup[w.id] = w 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} dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
for d in dep_rows: 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: if from_id in dep_index and to_id and to_id in ws_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub( dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type, 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("*")) .options(noload("*"))
.where(Decision.status == DecisionStatus.resolved) .where(Decision.status == DecisionStatus.resolved)
.where(Decision.decided_at >= cutoff) .where(Decision.decided_at >= cutoff)
.where(Decision.workstream_id.isnot(None)) .where(Decision.workplan_id.isnot(None))
.order_by(Decision.decided_at.desc()) .order_by(Decision.decided_at.desc())
.limit(20) .limit(20)
) )
@@ -839,7 +839,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
open_tasks_rows = await session.execute( open_tasks_rows = await session.execute(
select(Task) select(Task)
.options(noload("*")) .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])) .where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
) )
open_tasks = list(open_tasks_rows.scalars().all()) 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)) task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
if task.id in seen_task_ids: if task.id in seen_task_ids:
continue 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) domain_slug = await _get_domain_slug_for_workstream(ws, session)
steps.append(NextStep( steps.append(NextStep(
type="resolved_decision", type="resolved_decision",
@@ -868,13 +868,13 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
# ── Signal 2: cleared dependencies ────────────────────────────────────── # ── Signal 2: cleared dependencies ──────────────────────────────────────
all_dep_rows = await session.execute( all_dep_rows = await session.execute(
select( select(
WorkstreamDependency.from_workstream_id, WorkplanDependency.from_workplan_id,
WorkstreamDependency.to_workstream_id, WorkplanDependency.to_workplan_id,
).where(WorkstreamDependency.to_workstream_id.isnot(None)) ).where(WorkplanDependency.to_workplan_id.isnot(None))
) )
all_deps = all_dep_rows.all() 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_map: dict = {}
dep_ws_ids = set() dep_ws_ids = set()
for from_ws_id, to_ws_id in all_deps: 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: if dep_ws_ids:
ws_rows = await session.execute( ws_rows = await session.execute(
select( select(
Workstream.id, Workplan.id,
Workstream.status, Workplan.status,
Workstream.title, Workplan.title,
Workstream.slug, Workplan.slug,
Workstream.topic_id, Workplan.topic_id,
).where(Workstream.id.in_(dep_ws_ids)) ).where(Workplan.id.in_(dep_ws_ids))
) )
ws_info = { ws_info = {
ws_id: { ws_id: {
@@ -906,9 +906,9 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
ready_from_ws_ids = [ ready_from_ws_ids = [
from_ws_id from_ws_id
for from_ws_id, to_ws_ids in dep_map.items() 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( 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 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( todo_rows = await session.execute(
select(Task) select(Task)
.options(noload("*")) .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) .where(Task.status == TaskStatus.todo)
) )
for task in todo_rows.scalars().all(): 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: for from_ws_id in ready_from_ws_ids:
from_ws = ws_info.get(from_ws_id, {}) from_ws = ws_info.get(from_ws_id, {})
@@ -956,7 +956,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
return steps 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.""" """Get the domain slug for a workstream via its topic."""
if ws is None or ws.topic_id is None: if ws is None or ws.topic_id is None:
return 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.progress_event import ProgressEvent
from api.models.task import Task, TaskStatus from api.models.task import Task, TaskStatus
from api.models.token_event import TokenEvent from api.models.token_event import TokenEvent
from api.models.workstream import Workstream from api.models.workplan import Workplan
from api.schemas.task import ( from api.schemas.task import (
TaskCountRead, TaskCountRead,
TaskCreate, TaskCreate,
@@ -26,6 +26,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("/", response_model=list[TaskRead]) @router.get("/", response_model=list[TaskRead])
async def list_tasks( async def list_tasks(
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None,
status: str | None = None, status: str | None = None,
assignee: str | None = None, assignee: str | None = None,
@@ -37,8 +38,9 @@ async def list_tasks(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[Task]: ) -> list[Task]:
q = select(Task) q = select(Task)
if workstream_id: scope_id = workplan_id or workstream_id
q = q.where(Task.workstream_id == workstream_id) if scope_id:
q = q.where(Task.workplan_id == scope_id)
if status: if status:
q = q.where(Task.status == TaskStatus(normalize_task_status(status))) q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
if assignee: if assignee:
@@ -60,18 +62,20 @@ async def list_tasks(
@router.get("/counts", response_model=list[TaskCountRead]) @router.get("/counts", response_model=list[TaskCountRead])
async def count_tasks( async def count_tasks(
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None,
status: str | None = None, status: str | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[TaskCountRead]: ) -> list[TaskCountRead]:
q = select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status) q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
if workstream_id: scope_id = workplan_id or workstream_id
q = q.where(Task.workstream_id == workstream_id) if scope_id:
q = q.where(Task.workplan_id == scope_id)
if status: if status:
q = q.where(Task.status == TaskStatus(normalize_task_status(status))) q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
rows = await session.execute(q) rows = await session.execute(q)
return [ 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 for ws_id, task_status, count in rows
] ]
@@ -84,7 +88,7 @@ async def create_task(
task = Task(**body.model_dump()) task = Task(**body.model_dump())
session.add(task) session.add(task)
if status_value(task.status) == "progress": 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( transition_task_status(
task, task,
task.status, task.status,
@@ -137,7 +141,7 @@ async def bulk_status_sync(
target_status = status_value(update.status) target_status = status_value(update.status)
if update.blocking_reason is not None: if update.blocking_reason is not None:
task.blocking_reason = update.blocking_reason 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( transition_task_status(
task, task,
update.status, update.status,
@@ -146,7 +150,7 @@ async def bulk_status_sync(
) )
event = ProgressEvent( event = ProgressEvent(
task_id=task.id, task_id=task.id,
workstream_id=task.workstream_id, workplan_id=task.workplan_id,
event_type="task_status_changed", event_type="task_status_changed",
summary=f"Task status -> {target_status}: {task.title}", summary=f"Task status -> {target_status}: {task.title}",
author=author, author=author,
@@ -218,7 +222,7 @@ async def update_task(
for field, value in update_data.items(): for field, value in update_data.items():
setattr(task, field, value) setattr(task, field, value)
if new_status is not None: 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( transition_task_status(
task, task,
status_update, status_update,
@@ -247,7 +251,7 @@ async def update_task(
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data: elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
# Tier 2: prorate workplan total across task count # Tier 2: prorate workplan total across task count
count_result = await session.execute( 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) task_count = max(count_result.scalar() or 1, 1)
tin = token_data["workplan_tokens_in"] // task_count tin = token_data["workplan_tokens_in"] // task_count
@@ -273,12 +277,12 @@ async def update_task(
raw_metadata = {"estimation_method": "fixed_task_done_fallback"} raw_metadata = {"estimation_method": "fixed_task_done_fallback"}
# Resolve repo_id via workstream # 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 repo_id = ws.repo_id if ws else None
event = TokenEvent( event = TokenEvent(
task_id=task_id, task_id=task_id,
workstream_id=task.workstream_id, workplan_id=task.workplan_id,
repo_id=repo_id, repo_id=repo_id,
tokens_in=tin, tokens_in=tin,
tokens_out=tout, 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.managed_repo import ManagedRepo
from api.models.task import Task from api.models.task import Task
from api.models.token_event import TokenEvent 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 ( from api.schemas.token_event import (
RepoTokenSummary, RepoTokenSummary,
TokenAggregateRow, 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]: async def _populate_relationship_defaults(data: dict[str, Any], session: AsyncSession) -> dict[str, Any]:
# Auto-populate workstream_id from task if not provided # 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"]) task = await session.get(Task, data["task_id"])
if task: if task:
data["workstream_id"] = task.workstream_id data["workplan_id"] = task.workplan_id
# Auto-populate repo_id from workstream if not provided # Auto-populate repo_id from workstream if not provided
if data.get("workstream_id") and not data.get("repo_id"): if data.get("workplan_id") and not data.get("repo_id"):
ws = await session.get(Workstream, data["workstream_id"]) ws = await session.get(Workplan, data["workplan_id"])
if ws and ws.repo_id: if ws and ws.repo_id:
data["repo_id"] = ws.repo_id data["repo_id"] = ws.repo_id
return data return data
@@ -169,7 +169,7 @@ def _filter_query(
if task_id: if task_id:
q = q.where(TokenEvent.task_id == task_id) q = q.where(TokenEvent.task_id == task_id)
if workstream_id: if workstream_id:
q = q.where(TokenEvent.workstream_id == workstream_id) q = q.where(TokenEvent.workplan_id == workstream_id)
if repo_id: if repo_id:
q = q.where(TokenEvent.repo_id == repo_id) q = q.where(TokenEvent.repo_id == repo_id)
if ref_type: if ref_type:
@@ -195,7 +195,7 @@ def _filter_query(
if unattributed: if unattributed:
q = q.where( q = q.where(
TokenEvent.repo_id.is_(None), TokenEvent.repo_id.is_(None),
TokenEvent.workstream_id.is_(None), TokenEvent.workplan_id.is_(None),
TokenEvent.task_id.is_(None), TokenEvent.task_id.is_(None),
) )
return q return q
@@ -238,7 +238,7 @@ async def get_token_summary(
uid = uuid.UUID(id) uid = uuid.UUID(id)
except ValueError: except ValueError:
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream") 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": elif scope == "repo":
try: try:
uid = uuid.UUID(id) uid = uuid.UUID(id)
@@ -297,7 +297,7 @@ async def get_tokens_by_repo(
Resolution order for each event: Resolution order for each event:
1. token_events.repo_id (direct) 1. token_events.repo_id (direct)
2. → workstreams.repo_id (via workstream_id) 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. 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()) events = list(events_result.scalars().all())
ws_result = await session.execute(select(Workstream)) ws_result = await session.execute(select(Workplan))
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()} ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
task_result = await session.execute(select(Task)) task_result = await session.execute(select(Task))
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()} 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: def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
if e.repo_id: if e.repo_id:
return 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: 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: if ws_id and ws_id in ws_map:
return ws_map[ws_id].repo_id return ws_map[ws_id].repo_id
return None return None
@@ -391,8 +391,8 @@ async def get_token_aggregate(
) )
events = list(events_result.scalars().all()) events = list(events_result.scalars().all())
ws_result = await session.execute(select(Workstream)) ws_result = await session.execute(select(Workplan))
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()} ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
task_result = await session.execute(select(Task)) task_result = await session.execute(select(Task))
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()} 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: def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
if e.repo_id: if e.repo_id:
return 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: 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: if ws_id and ws_id in ws_map:
return ws_map[ws_id].repo_id return ws_map[ws_id].repo_id
return None return None
@@ -458,7 +458,7 @@ async def get_token_aggregate(
repo = repo_map.get(rid) if rid else None 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) 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 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) 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 source_counts[(e.measurement_kind, e.source_provider, e.source_id)] += 1
if e.source_provider == "task_fallback" or e.note == "heuristic": if e.source_provider == "task_fallback" or e.note == "heuristic":
fallback_count += 1 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 unattributed_measured_count += 1
if e.measurement_kind == "measured" and not e.source_id: if e.measurement_kind == "measured" and not e.source_id:
missing_provenance_count += 1 missing_provenance_count += 1

View File

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

View File

@@ -6,9 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.task import Task from api.models.task import Task
from api.models.workstream import Workstream from api.models.workplan import Workplan
from api.models.workstream_dependency import WorkstreamDependency from api.models.workplan_dependency import WorkplanDependency
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead
from api.routers.workstreams import _legacy_key, _meter_legacy_route from api.routers.workstreams import _legacy_key, _meter_legacy_route
router = APIRouter(prefix="/workstreams", tags=["dependencies"]) router = APIRouter(prefix="/workstreams", tags=["dependencies"])
@@ -17,28 +17,28 @@ workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"])
async def _create_dependency( async def _create_dependency(
*, *,
workstream_id: uuid.UUID, workplan_id: uuid.UUID,
body: WorkstreamDependencyCreate, body: WorkplanDependencyCreate,
session: AsyncSession, session: AsyncSession,
) -> WorkstreamDependency: ) -> WorkplanDependency:
if await session.get(Workstream, workstream_id) is None: if await session.get(Workplan, workplan_id) is None:
raise HTTPException(status_code=404, detail="from workplan not found") 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 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") 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") 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: 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") 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") raise HTTPException(status_code=422, detail="a workplan cannot depend on itself")
dep = WorkstreamDependency( dep = WorkplanDependency(
from_workstream_id=workstream_id, from_workplan_id=workplan_id,
to_workstream_id=body.to_workstream_id, to_workplan_id=body.to_workplan_id,
to_task_id=body.to_task_id, to_task_id=body.to_task_id,
relationship_type=body.relationship_type, relationship_type=body.relationship_type,
description=body.description, description=body.description,
@@ -51,15 +51,15 @@ async def _create_dependency(
async def _list_dependencies( async def _list_dependencies(
*, *,
workstream_id: uuid.UUID, workplan_id: uuid.UUID,
session: AsyncSession, session: AsyncSession,
) -> list[WorkstreamDependency]: ) -> list[WorkplanDependency]:
if await session.get(Workstream, workstream_id) is None: if await session.get(Workplan, workplan_id) is None:
raise HTTPException(status_code=404, detail="workplan not found") raise HTTPException(status_code=404, detail="workplan not found")
rows = await session.execute( rows = await session.execute(
select(WorkstreamDependency).where( select(WorkplanDependency).where(
(WorkstreamDependency.from_workstream_id == workstream_id) (WorkplanDependency.from_workplan_id == workplan_id)
| (WorkstreamDependency.to_workstream_id == workstream_id) | (WorkplanDependency.to_workplan_id == workplan_id)
) )
) )
return list(rows.scalars().all()) return list(rows.scalars().all())
@@ -67,14 +67,14 @@ async def _list_dependencies(
async def _delete_dependency( async def _delete_dependency(
*, *,
workstream_id: uuid.UUID, workplan_id: uuid.UUID,
dep_id: uuid.UUID, dep_id: uuid.UUID,
session: AsyncSession, session: AsyncSession,
) -> None: ) -> None:
dep = await session.get(WorkstreamDependency, dep_id) dep = await session.get(WorkplanDependency, dep_id)
if dep is None: if dep is None:
raise HTTPException(status_code=404, detail="dependency not found") 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") raise HTTPException(status_code=403, detail="dependency does not belong to this workplan")
await session.delete(dep) await session.delete(dep)
await session.commit() await session.commit()
@@ -82,17 +82,17 @@ async def _delete_dependency(
@router.post( @router.post(
"/{workstream_id}/dependencies/", "/{workstream_id}/dependencies/",
response_model=WorkstreamDependencyRead, response_model=WorkplanDependencyRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
) )
async def create_dependency( async def create_dependency(
request: Request, request: Request,
response: Response, response: Response,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
body: WorkstreamDependencyCreate, body: WorkplanDependencyCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> WorkstreamDependency: ) -> WorkplanDependency:
"""Record that workstream_id depends on another workstream or a task.""" """Record that workstream_id depends on another workplan or a task."""
await _meter_legacy_route( await _meter_legacy_route(
session=session, session=session,
request=request, request=request,
@@ -100,33 +100,33 @@ async def create_dependency(
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"), interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
replacement_ref="/workplans/{workplan_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_router.post(
"/{workplan_id}/dependencies/", "/{workplan_id}/dependencies/",
response_model=WorkstreamDependencyRead, response_model=WorkplanDependencyRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
) )
async def create_workplan_dependency( async def create_workplan_dependency(
workplan_id: uuid.UUID, workplan_id: uuid.UUID,
body: WorkstreamDependencyCreate, body: WorkplanDependencyCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> WorkstreamDependency: ) -> WorkplanDependency:
return await _create_dependency(workstream_id=workplan_id, body=body, session=session) return await _create_dependency(workplan_id=workplan_id, body=body, session=session)
@router.get( @router.get(
"/{workstream_id}/dependencies/", "/{workstream_id}/dependencies/",
response_model=list[WorkstreamDependencyRead], response_model=list[WorkplanDependencyRead],
) )
async def list_dependencies( async def list_dependencies(
request: Request, request: Request,
response: Response, response: Response,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[WorkstreamDependency]: ) -> list[WorkplanDependency]:
"""Return all dependency edges touching this workstream (both directions).""" """Return all dependency edges touching this workplan (both directions)."""
await _meter_legacy_route( await _meter_legacy_route(
session=session, session=session,
request=request, request=request,
@@ -134,18 +134,18 @@ async def list_dependencies(
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"), interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
replacement_ref="/workplans/{workplan_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_router.get(
"/{workplan_id}/dependencies/", "/{workplan_id}/dependencies/",
response_model=list[WorkstreamDependencyRead], response_model=list[WorkplanDependencyRead],
) )
async def list_workplan_dependencies( async def list_workplan_dependencies(
workplan_id: uuid.UUID, workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[WorkstreamDependency]: ) -> list[WorkplanDependency]:
return await _list_dependencies(workstream_id=workplan_id, session=session) return await _list_dependencies(workplan_id=workplan_id, session=session)
@router.delete( @router.delete(
@@ -167,7 +167,7 @@ async def delete_dependency(
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"), interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
replacement_ref="/workplans/{workplan_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( @workplan_router.delete(
@@ -179,4 +179,4 @@ async def delete_workplan_dependency(
dep_id: uuid.UUID, dep_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> None: ) -> 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.database import get_session
from api.events import EventEnvelope, publish_event from api.events import EventEnvelope, publish_event
from api.models.managed_repo import ManagedRepo from api.models.managed_repo import ManagedRepo
from api.models.workstream import Workstream from api.models.workplan import Workplan
from api.schemas.workstream import ( from api.schemas.workplan import (
WorkstreamCreate, WorkplanCreate,
WorkstreamRead, WorkplanRead,
WorkstreamUpdate, WorkplanUpdate,
) )
from api.services.lifecycle import transition_workstream_status from api.services.lifecycle import transition_workplan_status
from api.services.legacy_meter import ( from api.services.legacy_meter import (
LegacyUsageIdentity, LegacyUsageIdentity,
identity_from_request, identity_from_request,
record_legacy_usage, record_legacy_usage,
) )
from api.workplan_status import ( from api.workplan_status import (
is_supported_workstream_status, is_supported_workplan_status,
normalize_workstream_status, normalize_workplan_status,
ready_review_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) 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, topic_id: uuid.UUID | None,
repo_id: uuid.UUID | None, repo_id: uuid.UUID | None,
@@ -147,27 +147,27 @@ async def _list_workstreams(
owner: str | None, owner: str | None,
slug: str | None, slug: str | None,
session: AsyncSession, session: AsyncSession,
) -> list[Workstream]: ) -> list[Workplan]:
q = select(Workstream) q = select(Workplan)
if topic_id: if topic_id:
q = q.where(Workstream.topic_id == topic_id) q = q.where(Workplan.topic_id == topic_id)
if repo_id: if repo_id:
q = q.where(Workstream.repo_id == repo_id) q = q.where(Workplan.repo_id == repo_id)
if repo_goal_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: if status_filter:
normalised_status = normalize_workstream_status(status_filter) normalised_status = normalize_workplan_status(status_filter)
if not is_supported_workstream_status(status_filter): if not is_supported_workplan_status(status_filter):
raise HTTPException(status_code=422, detail=f"Unsupported 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: if owner:
q = q.where(Workstream.owner == owner) q = q.where(Workplan.owner == owner)
if slug: if slug:
q = q.where(Workstream.slug == slug) q = q.where(Workplan.slug == slug)
q = q.order_by( q = q.order_by(
Workstream.planning_priority.asc().nullslast(), Workplan.planning_priority.asc().nullslast(),
Workstream.planning_order.asc().nullslast(), Workplan.planning_order.asc().nullslast(),
Workstream.updated_at.desc(), Workplan.updated_at.desc(),
) )
result = await session.execute(q) result = await session.execute(q)
return list(result.scalars().all()) return list(result.scalars().all())
@@ -190,10 +190,10 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
continue continue
for path in sorted(directory.glob("*.md")): for path in sorted(directory.glob("*.md")):
data = _frontmatter(path) data = _frontmatter(path)
workstream_id = data.get("state_hub_workstream_id") workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id")
if not workstream_id: if not workplan_id:
continue continue
file_status = normalize_workstream_status(data.get("status", "")) file_status = normalize_workplan_status(data.get("status", ""))
review = ( review = (
ready_review_status( ready_review_status(
root, root,
@@ -203,7 +203,7 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
if file_status == "ready" if file_status == "ready"
else None else None
) )
index[str(workstream_id)] = { index[str(workplan_id)] = {
"filename": path.name, "filename": path.name,
"relative_path": str(path.relative_to(root)), "relative_path": str(path.relative_to(root)),
"repo_slug": repo.slug, "repo_slug": repo.slug,
@@ -287,79 +287,79 @@ async def _workplan_index(
return _INDEX_CACHE return _INDEX_CACHE
async def _create_workstream( async def _create_workplan(
*, *,
body: WorkstreamCreate, body: WorkplanCreate,
session: AsyncSession, session: AsyncSession,
) -> Workstream: ) -> Workplan:
ws = Workstream(**body.model_dump()) wp = Workplan(**body.model_dump())
session.add(ws) session.add(wp)
await session.commit() await session.commit()
await session.refresh(ws) await session.refresh(wp)
return ws return wp
async def _get_workstream( async def _get_workplan(
*, *,
workstream_id: uuid.UUID, workplan_id: uuid.UUID,
session: AsyncSession, session: AsyncSession,
) -> Workstream: ) -> Workplan:
ws = await session.get(Workstream, workstream_id) wp = await session.get(Workplan, workplan_id)
if ws is None: if wp is None:
raise HTTPException(status_code=404, detail="Workplan not found") 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, workplan_id: uuid.UUID,
body: WorkstreamUpdate, body: WorkplanUpdate,
session: AsyncSession, session: AsyncSession,
) -> Workstream: ) -> Workplan:
ws = await session.get(Workstream, workstream_id) wp = await session.get(Workplan, workplan_id)
if ws is None: if wp is None:
raise HTTPException(status_code=404, detail="Workplan not found") raise HTTPException(status_code=404, detail="Workplan not found")
update_data = body.model_dump(exclude_unset=True) update_data = body.model_dump(exclude_unset=True)
status_update = update_data.pop("status", None) status_update = update_data.pop("status", None)
prev_status = ws.status prev_status = wp.status
for field, value in update_data.items(): for field, value in update_data.items():
setattr(ws, field, value) setattr(wp, field, value)
if status_update is not None: if status_update is not None:
transition_workstream_status(ws, status_update) transition_workplan_status(wp, status_update)
await session.commit() await session.commit()
await session.refresh(ws) await session.refresh(wp)
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished": if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished":
await _publish_completion_events(ws, session) 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, session: AsyncSession,
) -> Workstream: ) -> Workplan:
ws = await session.get(Workstream, workstream_id) wp = await session.get(Workplan, workplan_id)
if ws is None: if wp is None:
raise HTTPException(status_code=404, detail="Workplan not found") raise HTTPException(status_code=404, detail="Workplan not found")
transition_workstream_status(ws, "archived") transition_workplan_status(wp, "archived")
await session.commit() await session.commit()
await session.refresh(ws) await session.refresh(wp)
return ws 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( workplan_envelope = EventEnvelope.new(
_COMPLETED_WORKPLAN_EVENT, _COMPLETED_WORKPLAN_EVENT,
attributes={ attributes={
"workplan_id": str(ws.id), "workplan_id": str(wp.id),
"legacy_workstream_id": str(ws.id), "legacy_workstream_id": str(wp.id),
"slug": ws.slug, "slug": wp.slug,
"title": ws.title, "title": wp.title,
"topic_id": str(ws.topic_id), "topic_id": str(wp.topic_id) if wp.topic_id else None,
"repo_id": str(ws.repo_id) if ws.repo_id else None, "repo_id": str(wp.repo_id) if wp.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_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)) 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( legacy_envelope = EventEnvelope.new(
_COMPLETED_WORKSTREAM_EVENT, _COMPLETED_WORKSTREAM_EVENT,
attributes={ attributes={
"workstream_id": str(ws.id), "workstream_id": str(wp.id),
"slug": ws.slug, "slug": wp.slug,
"title": ws.title, "title": wp.title,
"topic_id": str(ws.topic_id), "topic_id": str(wp.topic_id) if wp.topic_id else None,
"repo_id": str(ws.repo_id) if ws.repo_id else None, "repo_id": str(wp.repo_id) if wp.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_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)) 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( async def list_workstreams(
request: Request, request: Request,
response: Response, response: Response,
@@ -394,7 +394,7 @@ async def list_workstreams(
owner: str | None = None, owner: str | None = None,
slug: str | None = None, slug: str | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[Workstream]: ) -> list[Workplan]:
await _meter_legacy_route( await _meter_legacy_route(
session=session, session=session,
request=request, request=request,
@@ -402,7 +402,7 @@ async def list_workstreams(
interface_key=_legacy_key("GET", "/workstreams/"), interface_key=_legacy_key("GET", "/workstreams/"),
replacement_ref="/workplans/", replacement_ref="/workplans/",
) )
return await _list_workstreams( return await _list_workplans(
topic_id=topic_id, topic_id=topic_id,
repo_id=repo_id, repo_id=repo_id,
repo_goal_id=repo_goal_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( async def list_workplans(
topic_id: uuid.UUID | None = None, topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None, repo_id: uuid.UUID | None = None,
@@ -422,8 +422,8 @@ async def list_workplans(
owner: str | None = None, owner: str | None = None,
slug: str | None = None, slug: str | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[Workstream]: ) -> list[Workplan]:
return await _list_workstreams( return await _list_workplans(
topic_id=topic_id, topic_id=topic_id,
repo_id=repo_id, repo_id=repo_id,
repo_goal_id=repo_goal_id, repo_goal_id=repo_goal_id,
@@ -459,13 +459,13 @@ async def workplan_index_preferred(
return await _workplan_index(refresh=refresh, session=session) 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( async def create_workstream(
request: Request, request: Request,
response: Response, response: Response,
body: WorkstreamCreate, body: WorkplanCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workstream: ) -> Workplan:
await _meter_legacy_route( await _meter_legacy_route(
session=session, session=session,
request=request, request=request,
@@ -473,24 +473,24 @@ async def create_workstream(
interface_key=_legacy_key("POST", "/workstreams/"), interface_key=_legacy_key("POST", "/workstreams/"),
replacement_ref="/workplans/", 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( async def create_workplan(
body: WorkstreamCreate, body: WorkplanCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workstream: ) -> Workplan:
return await _create_workstream(body=body, session=session) 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( async def get_workstream(
request: Request, request: Request,
response: Response, response: Response,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workstream: ) -> Workplan:
await _meter_legacy_route( await _meter_legacy_route(
session=session, session=session,
request=request, request=request,
@@ -498,25 +498,25 @@ async def get_workstream(
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"), interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_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( async def get_workplan(
workplan_id: uuid.UUID, workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workstream: ) -> Workplan:
return await _get_workstream(workstream_id=workplan_id, session=session) 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( async def update_workstream(
request: Request, request: Request,
response: Response, response: Response,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
body: WorkstreamUpdate, body: WorkplanUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workstream: ) -> Workplan:
await _meter_legacy_route( await _meter_legacy_route(
session=session, session=session,
request=request, request=request,
@@ -524,25 +524,25 @@ async def update_workstream(
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"), interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_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( async def update_workplan(
workplan_id: uuid.UUID, workplan_id: uuid.UUID,
body: WorkstreamUpdate, body: WorkplanUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workstream: ) -> Workplan:
return await _update_workstream(workstream_id=workplan_id, body=body, session=session) 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( async def archive_workstream(
request: Request, request: Request,
response: Response, response: Response,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workstream: ) -> Workplan:
await _meter_legacy_route( await _meter_legacy_route(
session=session, session=session,
request=request, request=request,
@@ -550,12 +550,12 @@ async def archive_workstream(
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"), interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_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( async def archive_workplan(
workplan_id: uuid.UUID, workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workstream: ) -> Workplan:
return await _archive_workstream(workstream_id=workplan_id, session=session) 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.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.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
@@ -9,6 +10,7 @@ from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead
__all__ = [ __all__ = [
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams", "TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
"WorkplanCreate", "WorkplanUpdate", "WorkplanRead",
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead", "WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
"TaskCreate", "TaskUpdate", "TaskRead", "TaskCreate", "TaskUpdate", "TaskRead",
"DecisionCreate", "DecisionUpdate", "DecisionRead", "DecisionCreate", "DecisionUpdate", "DecisionRead",

View File

@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
from hub_core.schemas.capability import ( from hub_core.schemas.capability import (
CapabilityRequestDispute, CapabilityRequestDispute,
@@ -23,20 +23,29 @@ class CapabilityRequestCreate(BaseModel):
priority: str = "medium" priority: str = "medium"
requesting_domain: str # slug, resolved to domain_id in router requesting_domain: str # slug, resolved to domain_id in router
requesting_agent: str 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 blocking_task_id: uuid.UUID | None = None
class CapabilityRequestAccept(BaseModel): class CapabilityRequestAccept(BaseModel):
fulfilling_agent: str 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): class CapabilityRequestPatch(BaseModel):
catalog_entry_id: uuid.UUID | None = None catalog_entry_id: uuid.UUID | None = None
priority: str | None = None priority: str | None = None
blocking_task_id: uuid.UUID | 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): class CapabilityRequestReroute(BaseModel):
@@ -57,10 +66,10 @@ class CapabilityRequestRead(BaseModel):
status: str status: str
requesting_domain_slug: str requesting_domain_slug: str
requesting_agent: 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_domain_slug: str | None = None
fulfilling_agent: 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 blocking_task_id: uuid.UUID | None = None
catalog_entry_id: uuid.UUID | None = None catalog_entry_id: uuid.UUID | None = None
resolution_note: str | None = None resolution_note: str | None = None
@@ -73,3 +82,13 @@ class CapabilityRequestRead(BaseModel):
completed_at: datetime | None = None completed_at: datetime | None = None
created_at: datetime created_at: datetime
updated_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 import uuid
from datetime import datetime 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 from api.models.contribution import ContributionStatus, ContributionType
@@ -14,7 +14,10 @@ class ContributionCreate(BaseModel):
title: str title: str
body_path: str | None = None body_path: str | None = None
related_topic_id: uuid.UUID | 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 repo_id: uuid.UUID | None = None
notes: str | None = None notes: str | None = None
@@ -36,10 +39,15 @@ class ContributionRead(BaseModel):
status: ContributionStatus status: ContributionStatus
body_path: str | None = None body_path: str | None = None
related_topic_id: uuid.UUID | 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 repo_id: uuid.UUID | None = None
submitted_at: datetime | None = None submitted_at: datetime | None = None
resolved_at: datetime | None = None resolved_at: datetime | None = None
notes: str | None = None notes: str | None = None
created_at: datetime created_at: datetime
updated_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 pydantic import BaseModel, ConfigDict, model_validator
from api.models.decision import DecisionStatus, DecisionType from api.models.decision import DecisionStatus, DecisionType
from api.schemas.compat import OptionalWorkplanIdCompatMixin
from pydantic import AliasChoices, Field
class DecisionCreate(BaseModel): class DecisionCreate(BaseModel):
topic_id: uuid.UUID | None = None 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 title: str
description: str | None = None description: str | None = None
decision_type: DecisionType = DecisionType.pending decision_type: DecisionType = DecisionType.pending
@@ -20,9 +25,9 @@ class DecisionCreate(BaseModel):
escalation_note: str | None = None escalation_note: str | None = None
@model_validator(mode="after") @model_validator(mode="after")
def topic_or_workstream_required(self) -> "DecisionCreate": def topic_or_workplan_required(self) -> "DecisionCreate":
if self.topic_id is None and self.workstream_id is None: if self.topic_id is None and self.workplan_id is None:
raise ValueError("At least one of topic_id or workstream_id must be set") raise ValueError("At least one of topic_id or workplan_id must be set")
return self return self
@@ -45,11 +50,10 @@ class DecisionUpdate(BaseModel):
superseded_by: uuid.UUID | None = None superseded_by: uuid.UUID | None = None
class DecisionRead(BaseModel): class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
topic_id: uuid.UUID | None = None topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
title: str title: str
description: str | None = None description: str | None = None
decision_type: DecisionType decision_type: DecisionType
@@ -61,4 +65,4 @@ class DecisionRead(BaseModel):
escalation_note: str | None = None escalation_note: str | None = None
superseded_by: uuid.UUID | None = None superseded_by: uuid.UUID | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

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

View File

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

View File

@@ -2,8 +2,10 @@ import uuid
from datetime import date, datetime from datetime import date, datetime
from typing import Any, Literal 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 ( from hub_core.schemas.managed_repo import (
RepoCreate as CoreRepoCreate, RepoCreate as CoreRepoCreate,
RepoPathRegister, 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 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 name: str | None = None
local_path: str | None = None local_path: str | None = None
remote_url: str | None = None remote_url: str | None = None
@@ -42,7 +106,7 @@ class RepoOnboardResult(BaseModel):
stderr: str = "" stderr: str = ""
class RepoRead(CoreRepoRead): class RepoRead(CoreRepoRead, ClassificationFields):
topic_id: uuid.UUID | None = None topic_id: uuid.UUID | None = None
sbom_source: str | None = None sbom_source: str | None = None
last_sbom_at: datetime | None = None last_sbom_at: datetime | None = None
@@ -59,13 +123,17 @@ class DispatchTask(BaseModel):
needs_human: bool needs_human: bool
class DispatchWorkstream(BaseModel): class DispatchWorkplan(BaseModel):
id: uuid.UUID id: uuid.UUID
title: str title: str
status: str status: str
pending_tasks: list[DispatchTask] pending_tasks: list[DispatchTask]
# Legacy alias
DispatchWorkstream = DispatchWorkplan
class PendingInterfaceChange(BaseModel): class PendingInterfaceChange(BaseModel):
id: uuid.UUID id: uuid.UUID
title: str title: str
@@ -90,13 +158,17 @@ class ScopeIssueDetail(BaseModel):
class RepoDispatch(BaseModel): class RepoDispatch(BaseModel):
repo_slug: str repo_slug: str
active_goal: dict[str, Any] | None active_goal: dict[str, Any] | None
active_workstreams: list[DispatchWorkstream] active_workplans: list[DispatchWorkplan]
human_interventions: list[DispatchTask] human_interventions: list[DispatchTask]
pending_interface_changes: list[PendingInterfaceChange] pending_interface_changes: list[PendingInterfaceChange]
scope_needs_review: bool scope_needs_review: bool
scope_issue_details: list[ScopeIssueDetail] scope_issue_details: list[ScopeIssueDetail]
last_state_synced_at: datetime | None last_state_synced_at: datetime | None
@property
def active_workstreams(self) -> list[DispatchWorkplan]:
return self.active_workplans
class RepoScopeHealth(BaseModel): class RepoScopeHealth(BaseModel):
repo_slug: str repo_slug: str
@@ -104,4 +176,4 @@ class RepoScopeHealth(BaseModel):
local_path: str | None = None local_path: str | None = None
path_available: bool path_available: bool
scope_needs_review: bool scope_needs_review: bool
scope_issue_details: list[ScopeIssueDetail] scope_issue_details: list[ScopeIssueDetail]

View File

@@ -2,12 +2,17 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any 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): class ProgressEventCreate(BaseModel):
topic_id: uuid.UUID | None = None 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 task_id: uuid.UUID | None = None
decision_id: uuid.UUID | None = None decision_id: uuid.UUID | None = None
event_type: str event_type: str
@@ -17,11 +22,10 @@ class ProgressEventCreate(BaseModel):
session_id: str | None = None session_id: str | None = None
class ProgressEventRead(BaseModel): class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
topic_id: uuid.UUID | None = None topic_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
task_id: uuid.UUID | None = None task_id: uuid.UUID | None = None
decision_id: uuid.UUID | None = None decision_id: uuid.UUID | None = None
event_type: str event_type: str
@@ -29,4 +33,4 @@ class ProgressEventRead(BaseModel):
detail: dict[str, Any] | None = None detail: dict[str, Any] | None = None
author: str | None = None author: str | None = None
session_id: str | None = None session_id: str | None = None
created_at: datetime created_at: datetime

View File

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

View File

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

View File

@@ -2,14 +2,19 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any 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): class TokenEventCreate(BaseModel):
tokens_in: int tokens_in: int
tokens_out: int tokens_out: int
task_id: uuid.UUID | 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 repo_id: uuid.UUID | None = None
session_id: str | None = None session_id: str | None = None
model: str | None = None model: str | None = None
@@ -32,14 +37,13 @@ class TokenEventCreate(BaseModel):
raw_metadata: dict[str, Any] | None = None raw_metadata: dict[str, Any] | None = None
class TokenEventRead(BaseModel): class TokenEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
tokens_in: int tokens_in: int
tokens_out: int tokens_out: int
task_id: uuid.UUID | None = None task_id: uuid.UUID | None = None
workstream_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None repo_id: uuid.UUID | None = None
session_id: str | None = None session_id: str | None = None
model: str | None = None model: str | None = None
@@ -90,7 +94,10 @@ class TokenEventPatch(BaseModel):
tokens_in: int | None = None tokens_in: int | None = None
tokens_out: int | None = None tokens_out: int | None = None
task_id: uuid.UUID | 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 repo_id: uuid.UUID | None = None
session_id: str | None = None session_id: str | None = None
note: 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 """Legacy aliases — prefer ``api.schemas.workplan``."""
from datetime import date, datetime from api.schemas.workplan import (
from typing import Literal 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 __all__ = [
from api.workplan_status import normalize_workstream_status "WorkstreamStatus",
"WorkstreamStatusMixin",
WorkstreamStatus = Literal[ "WorkstreamCreate",
"proposed", "WorkstreamUpdate",
"ready", "WorkstreamRead",
"active", "WorkstreamWithTaskCounts",
"blocked", "WorkstreamWithDeps",
"backlog", "WorkplanStatus",
"finished", "WorkplanStatusMixin",
"archived", "WorkplanCreate",
] "WorkplanUpdate",
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"] "WorkplanRead",
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"] "WorkplanWithTaskCounts",
ConcurrencyMode = Literal["sequential", "parallel"] "WorkplanWithDeps",
"ExecutionState",
"LaunchMode",
class WorkstreamStatusMixin(BaseModel): "ConcurrencyMode",
@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 """Legacy aliases — prefer ``api.schemas.workplan_dependency``."""
from datetime import datetime from api.schemas.workplan_dependency import (
WorkplanDepStub,
WorkplanDependencyCreate,
WorkplanDependencyRead,
)
from pydantic import BaseModel, ConfigDict WorkstreamDependencyCreate = WorkplanDependencyCreate
WorkstreamDependencyRead = WorkplanDependencyRead
WorkstreamDepStub = WorkplanDepStub
__all__ = [
class WorkstreamDependencyCreate(BaseModel): "WorkstreamDependencyCreate",
to_workstream_id: uuid.UUID | None = None "WorkstreamDependencyRead",
to_task_id: uuid.UUID | None = None "WorkstreamDepStub",
relationship_type: str = "blocks" "WorkplanDependencyCreate",
description: str | None = None "WorkplanDependencyRead",
"WorkplanDepStub",
]
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

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from api.workplan_status import normalize_workstream_status from api.workplan_status import normalize_workplan_status
EXECUTION_STATES = { EXECUTION_STATES = {
@@ -57,7 +57,7 @@ PRIORITY_RANK = {
"low": 3, "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: 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" return "queued"
def workstream_blockers( def workplan_blockers(
workstream_id: Any, workplan_id: Any,
dependency_targets: dict[Any, list[Any]], dependency_targets: dict[Any, list[Any]],
workstream_status: dict[Any, str], workplan_status: dict[Any, str],
workstream_id: Any = None,
) -> list[Any]: ) -> list[Any]:
scope_id = workplan_id if workplan_id is not None else workstream_id
blockers = [] blockers = []
for target_id in dependency_targets.get(workstream_id, []): for target_id in dependency_targets.get(scope_id, []):
target_status = normalize_workstream_status(workstream_status.get(target_id)) target_status = normalize_workplan_status(workplan_status.get(target_id))
if target_status not in CLOSED_WORKSTREAM_STATUSES: if target_status not in CLOSED_WORKPLAN_STATUSES:
blockers.append(target_id) blockers.append(target_id)
return blockers return blockers
workstream_blockers = workplan_blockers
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]: def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower() priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower()
execution_state = str(getattr(workstream, "execution_state", "") or "manual").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 typing import Any
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value 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_STARTED_STATUS = "progress"
TASK_NOT_STARTED_STATUS = "todo" TASK_NOT_STARTED_STATUS = "todo"
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"} PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
# Legacy alias
normalize_workstream_status = normalize_workplan_status
@dataclass(frozen=True) @dataclass(frozen=True)
class LifecycleTransitionResult: class LifecycleTransitionResult:
@@ -26,13 +28,15 @@ def should_activate_parent_for_task_start(
*, *,
previous_task_status: Any, previous_task_status: Any,
new_task_status: Any, new_task_status: Any,
parent_workstream_status: Any, parent_workplan_status: Any = None,
parent_workstream_status: Any = None,
) -> bool: ) -> bool:
"""Return whether a task start should move its parent to active.""" """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 ( return (
status_value(previous_task_status) == TASK_NOT_STARTED_STATUS status_value(previous_task_status) == TASK_NOT_STARTED_STATUS
and status_value(new_task_status) == TASK_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 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( 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, ...], task_statuses: list[Any] | tuple[Any, ...],
) -> bool: ) -> 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 ( return (
normalize_workstream_status(parent_workstream_status) normalize_workplan_status(parent_status)
in PARENT_ACTIVATION_STATUSES in PARENT_ACTIVATION_STATUSES
and has_active_task_status(task_statuses) and has_active_task_status(task_statuses)
) )
@@ -59,46 +65,54 @@ def activate_parent_for_task_start(
*, *,
previous_task_status: Any, previous_task_status: Any,
new_task_status: Any, new_task_status: Any,
parent_workstream: Any, parent_workplan: Any = None,
parent_workstream: Any = None,
) -> bool: ) -> bool:
"""Activate a planning-state parent workstream when real task work starts.""" """Activate a planning-state parent workplan when real task work starts."""
if parent_workstream is None: parent = parent_workplan if parent_workplan is not None else parent_workstream
if parent is None:
return False return False
if not should_activate_parent_for_task_start( if not should_activate_parent_for_task_start(
previous_task_status=previous_task_status, previous_task_status=previous_task_status,
new_task_status=new_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 return False
parent_workstream.status = "active" parent.status = "active"
return True return True
def transition_workstream_status( def transition_workplan_status(
workstream: Any, workplan: Any,
target_status: Any, target_status: Any,
) -> LifecycleTransitionResult: ) -> LifecycleTransitionResult:
"""Apply a canonical workstream status transition.""" """Apply a canonical workplan status transition."""
previous_status = normalize_workstream_status(getattr(workstream, "status", None)) previous_status = normalize_workplan_status(getattr(workplan, "status", None))
normalised_target = normalize_workstream_status(target_status) normalised_target = normalize_workplan_status(target_status)
workstream.status = normalised_target workplan.status = normalised_target
return LifecycleTransitionResult( return LifecycleTransitionResult(
entity_type="workstream", entity_type="workplan",
previous_status=previous_status, previous_status=previous_status,
target_status=normalised_target, target_status=normalised_target,
changed=previous_status != normalised_target, changed=previous_status != normalised_target,
) )
transition_workstream_status = transition_workplan_status
def transition_task_status( def transition_task_status(
task: Any, task: Any,
target_status: Any, target_status: Any,
*, *,
parent_workplan: Any = None,
parent_workstream: Any = None, parent_workstream: Any = None,
previous_task_status: Any = None, previous_task_status: Any = None,
status_coercer: Any = None, status_coercer: Any = None,
) -> LifecycleTransitionResult: ) -> LifecycleTransitionResult:
"""Apply a task status transition and activate the parent when work starts.""" """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( previous_status = status_value(
getattr(task, "status", None) getattr(task, "status", None)
if previous_task_status is None if previous_task_status is None
@@ -109,7 +123,8 @@ def transition_task_status(
parent_activated = activate_parent_for_task_start( parent_activated = activate_parent_for_task_start(
previous_task_status=previous_status, previous_task_status=previous_status,
new_task_status=normalised_target, new_task_status=normalised_target,
parent_workstream=parent_workstream, parent_workplan=parent,
parent_workstream=parent,
) )
return LifecycleTransitionResult( return LifecycleTransitionResult(
entity_type="task", entity_type="task",
@@ -117,4 +132,4 @@ def transition_task_status(
target_status=normalised_target, target_status=normalised_target,
changed=previous_status != normalised_target, changed=previous_status != normalised_target,
parent_activated=parent_activated, parent_activated=parent_activated,
) )

View File

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

View File

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

View File

@@ -41,9 +41,9 @@ def resolve_repo_path(repo: ManagedRepo | None) -> Path | None:
return None return None
def find_workplan_for_workstream( def find_workplan_for_workplan(
repo: ManagedRepo | None, repo: ManagedRepo | None,
workstream_id: uuid.UUID, workplan_id: uuid.UUID,
) -> WorkplanFileRef | None: ) -> WorkplanFileRef | None:
repo_path = resolve_repo_path(repo) repo_path = resolve_repo_path(repo)
if repo_path is None: if repo_path is None:
@@ -57,11 +57,15 @@ def find_workplan_for_workstream(
continue continue
for path in sorted(directory.glob("*.md")): for path in sorted(directory.glob("*.md")):
meta = _frontmatter(path) 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 WorkplanFileRef(repo_path=repo_path, path=path, archived=archived)
return None return None
find_workplan_for_workstream = find_workplan_for_workplan
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool: def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
return _task_block_for_task(path, task_id) is not None 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 from typing import Any
CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = ( CANONICAL_WORKPLAN_STATUSES: tuple[str, ...] = (
"proposed", "proposed",
"ready", "ready",
"active", "active",
@@ -17,22 +17,31 @@ CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
"archived", "archived",
) )
LEGACY_WORKSTREAM_STATUS_ALIASES: dict[str, str] = { LEGACY_WORKPLAN_STATUS_ALIASES: dict[str, str] = {
"todo": "ready", "todo": "ready",
"done": "finished", "done": "finished",
"completed": "finished", "completed": "finished",
"accepted": "finished", "accepted": "finished",
} }
SUPPORTED_WORKSTREAM_STATUSES: tuple[str, ...] = ( SUPPORTED_WORKPLAN_STATUSES: tuple[str, ...] = (
*CANONICAL_WORKSTREAM_STATUSES, *CANONICAL_WORKPLAN_STATUSES,
*LEGACY_WORKSTREAM_STATUS_ALIASES.keys(), *LEGACY_WORKPLAN_STATUS_ALIASES.keys(),
) )
OPEN_WORKSTREAM_STATUSES: tuple[str, ...] = ("ready", "active", "blocked") OPEN_WORKPLAN_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
CURRENT_WORKSTREAM_STATUSES: tuple[str, ...] = ("active", "blocked") CURRENT_WORKPLAN_STATUSES: tuple[str, ...] = ("active", "blocked")
CLOSED_WORKSTREAM_STATUSES: tuple[str, ...] = ("finished", "archived") CLOSED_WORKPLAN_STATUSES: tuple[str, ...] = ("finished", "archived")
PLANNING_WORKSTREAM_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog") 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) @dataclass(frozen=True)
@@ -42,26 +51,38 @@ class ReadyReviewStatus:
changed_paths: tuple[str, ...] = () 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.""" """Return the canonical lifecycle status for a stored or legacy value."""
value = _status_value(status) value = _status_value(status)
if value == "todo" and has_started: if value == "todo" and has_started:
return "active" 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: normalize_workstream_status = normalize_workplan_status
return _status_value(status) in CANONICAL_WORKSTREAM_STATUSES
def is_supported_workstream_status(status: Any) -> bool: def is_canonical_workplan_status(status: Any) -> bool:
return _status_value(status) in SUPPORTED_WORKSTREAM_STATUSES 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) return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
workstream_has_started = workplan_has_started
def ready_review_status( def ready_review_status(
repo_dir: str | Path, repo_dir: str | Path,
reviewed_against_commit: Any, reviewed_against_commit: Any,

View File

@@ -4,27 +4,36 @@ title: Domains — Reference
# Domains — Reference # Domains — Reference
The Domains page shows all registered project domains and the repositories The Domains page shows the **14 fixed market domains** from the Repo
associated with each one. Domains are the top-level organisational unit of the Classification Standard. These replaced the old ad-hoc coordination domains
Custodian ecosystem. (custodian, railiance, markitect, …) in STATE-WP-0065.
--- ---
## What is a domain? ## 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 | | `infotech` | Developers, platforms, internal tooling users |
| `railiance` | DevOps & infrastructure reliability | | `financials` | Finance, trading, payments |
| `markitect` | Knowledge artifact management | | `communication` | Messaging, social, collaboration |
| `coulomb_social` | Co-creation marketplace | | `consumer` | General consumers |
| `personhood` | Rights & obligations framework | | `health` | Healthcare, wellness |
| `foerster_capabilities` | Agency capability taxonomy | | `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 Canon: `the-custodian/canon/standards/repo-classification-standard_v1.0.md`
optional description, and a status.
--- ---
@@ -32,63 +41,21 @@ optional description, and a status.
| Status | Meaning | | Status | Meaning |
|--------|---------| |--------|---------|
| **active** | Live domain — topics, workstreams, and tasks are being tracked | | **active** | Live domain — repos and workplans may reference it |
| **archived** | Soft-deleted; no active work. Fails to archive if active topics exist | | **archived** | Retired; no new registrations |
--- ---
## KPI row ## Relationship to repos and workplans
Four counters at the top of the page: - **Repos** are the primary anchor — classification file is source of truth.
- **Workplans** require `repo_id`; market domain is derived from the repo.
| Counter | Meaning | - **Topics** are optional legacy tags; workplan frontmatter `domain:` may still
|---------|---------| use old coordination slugs — the consistency checker maps these to market domains.
| 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 |
--- ---
## Domain cards ## Related
One card per domain showing: - **[Repos](/docs/repos)** — portfolio view with category / capability filters
- **[Repo Integration](/docs/repo-integration)** — onboarding with classification file
- **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.*

View File

@@ -5,18 +5,25 @@ title: Repos — Reference
# Repos — Reference # Repos — Reference
The Repos page shows every repository registered in the Custodian ecosystem, 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? ## What is a managed repo?
A managed repo is a git repository that has been registered with the state hub A managed repo is a git repository registered with State Hub. Registration is
via `custodian register-project` or `register_repo()`. Registration records the **classification-driven**:
repo's slug, domain, local path, and optional remote URL. Once registered, the
repo receives a `CLAUDE.custodian.md` integration suggestion, an onboarding 1. Commit `.repo-classification.yaml` per the Repo Classification Standard.
workstream with 4 tasks for the repo agent, and is eligible for SBOM ingestion 2. Run `make register-from-classification REPO=<slug>` (or use the MCP tool
and the ADR-001 workplan validator. `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)**. 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 | | Card | Meaning |
|------|---------| |------|---------|
| **Registered Repos** | Active repos only (status = active) | | **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 Ingested** | Repos with at least one SBOM snapshot |
| **SBOM Gaps** | Repos with no ingested SBOM — red border when > 0 | | **SBOM Gaps** | Repos with no ingested SBOM — red border when > 0 |
--- ---
## Coverage Map ## Portfolio by Category
Groups repos by domain. Each domain block shows: Groups repos by `category` (experimental, research, project, tooling, product,
business). Each block shows domain, capabilities, business stake, and who
- **Domain name** with SBOM, EP, and TD chip indicators classified the repo (`human` vs `migration`).
- **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.
--- ---
## 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 | | Filter | Effect |
|--------|--------| |--------|--------|
| **Domain** | Show repos for a single domain only | | **Market domain** | Primary domain slug |
| **Gaps only** | Toggle to show only repos without an ingested SBOM | | **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 ## Onboarding a new repo
See **[Repo Integration](/docs/repo-integration)** for the full journey. Use the **Add Repo** form or:
Quick reference:
```bash ```bash
# From the repo root — registers, writes CLAUDE.custodian.md, creates onboarding tasks # 1. Author classification file in the repo
custodian register-project --domain <slug> # 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 integrating = !!integratingBySlug[r.slug];
const doiEntry = doiBySlug[r.slug] ?? null; const doiEntry = doiBySlug[r.slug] ?? null;
const doiTier = doiEntry?.tier ?? "none"; 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 { return {
_id: r.id, _id: r.id,
_domSlug: domSlug, _domSlug: domSlug,
_category: category,
_capList: capList,
_stakeList: stakeList,
_hasSbom: hasSbom, _hasSbom: hasSbom,
_integrating: integrating, _integrating: integrating,
_doiTier: doiTier, _doiTier: doiTier,
repo: r.slug, repo: r.slug,
domain: domName, domain: domName,
category: category,
capTags: capTags,
businessStake: stakeList.length ? stakeList.slice(0, 3).join(", ") : "—",
classifiedBy: classifiedBy,
status: integrating ? "⚙ integrating" : "ready", status: integrating ? "⚙ integrating" : "ready",
path: r.local_path ?? "—", path: r.local_path ?? "—",
sbom: hasSbom ? `${lastScan}` : "⚠ not ingested", sbom: hasSbom ? `${lastScan}` : "⚠ not ingested",
@@ -153,9 +167,13 @@ display(html`<div class="kpi-row">
<p class="big-num">${repoRows.length}</p> <p class="big-num">${repoRows.length}</p>
</div> </div>
<div class="card"> <div class="card">
<h3>Domains</h3> <h3>Market Domains</h3>
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p> <p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
</div> </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' : ''}"> <div class="card ${integratingCount > 0 ? 'card-integrating' : ''}">
<h3>Integrating</h3> <h3>Integrating</h3>
<p class="big-num">${integratingCount}</p> <p class="big-num">${integratingCount}</p>
@@ -240,6 +258,8 @@ if (domainBlocks.length === 0) {
<table class="repo-table"> <table class="repo-table">
<thead><tr> <thead><tr>
<th>Repo</th> <th>Repo</th>
<th>Category</th>
<th>Capabilities</th>
<th>DoI Tier</th> <th>DoI Tier</th>
<th>Status</th> <th>Status</th>
<th>SBOM</th> <th>SBOM</th>
@@ -249,6 +269,8 @@ if (domainBlocks.length === 0) {
<tbody> <tbody>
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}"> ${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
<td class="repo-cell"><code>${r.repo}</code></td> <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>${_doiBadge(r._doiTier)}</td>
<td>${r._integrating <td>${r._integrating
? html`<span class="chip chip-integrating">⚙ integrating</span>` ? html`<span class="chip chip-integrating">⚙ integrating</span>`
@@ -266,25 +288,76 @@ 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 ## All Repos Table
```js ```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 doiFilter = Inputs.select(["all", "none", "core", "standard", "full"], {label: "DoI tier", value: "all"}); const categoryFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._category).filter(c => c !== "—")).values()], {label: "Category", value: "all"});
const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false}); const capabilityFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._capList)).values()].sort(), {label: "Capability", value: "all"});
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">${domainFilter}${doiFilter}${gapFilter}</div>`); 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 class="filter-bar">${domainFilter}${categoryFilter}${capabilityFilter}${stakeFilter}${doiFilter}${gapFilter}</div>`);
``` ```
```js ```js
const filteredRows = repoRows.filter(r => const filteredRows = repoRows.filter(r =>
(domainFilter.value === "all" || r._domSlug === domainFilter.value) && (domainFilter.value === "all" || r._domSlug === domainFilter.value) &&
(doiFilter.value === "all" || r._doiTier === doiFilter.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) (!gapFilter.value || !r._hasSbom)
); );
display(Inputs.table(filteredRows.map(r => ({ display(Inputs.table(filteredRows.map(r => ({
Repo: r.repo, Repo: r.repo,
Domain: r.domain, 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, "DoI Tier": DOI_TIER_LABEL[r._doiTier] ?? r._doiTier,
Status: r.status, Status: r.status,
SBOM: r.sbom, 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 ## MCP/REST Parity and Failure Handling
The MCP server is a thin stateless HTTP client over the FastAPI service. On 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 | | 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_workstream(...)` | `POST /workstreams/` |
| `create_task(...)` | `POST /tasks/` | | `create_task(...)` | `POST /tasks/` |
| `update_task_status(...)` | `PATCH /tasks/{task_id}` | | `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() @mcp.tool()
def list_tasks(workstream_id: str, status: str | None = None) -> str: def list_tasks(
"""List all tasks in a workstream, optionally filtered by status. 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: 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. status: Optional filter — wait | todo | progress | done | cancel.
Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every 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, 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. 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() @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 _workplan_id_from_response(payload: dict[str, Any]) -> str | None:
def create_workstream( return payload.get("workplan_id") or payload.get("workstream_id") or payload.get("id")
topic_id: str,
def _create_workplan_impl(
*,
repo_id: str,
title: str, title: str,
topic_id: str | None = None,
slug: str | None = None, slug: str | None = None,
description: str | None = None, description: str | None = None,
owner: str | None = None, owner: str | None = None,
due_date: str | None = None, due_date: str | None = None,
repo_id: str | None = None,
planning_priority: str | None = None, planning_priority: str | None = None,
planning_order: int | None = None, planning_order: int | None = None,
tool_name: str = "create_workplan",
) -> str: ) -> 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: if not slug:
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
ws = _post("/workstreams", { wp = _post("/workplans", {
"repo_id": repo_id,
"topic_id": topic_id, "topic_id": topic_id,
"title": title, "title": title,
"slug": slug, "slug": slug,
@@ -493,30 +497,147 @@ def create_workstream(
"owner": owner, "owner": owner,
"due_date": due_date, "due_date": due_date,
"status": "active", "status": "active",
"repo_id": repo_id,
"planning_priority": planning_priority, "planning_priority": planning_priority,
"planning_order": planning_order, "planning_order": planning_order,
}) })
if error := _response_error("create_workstream", ws, ("id",)): if error := _response_error(tool_name, wp, ("id",)):
return _json_result(error) return _json_result(error)
progress_error = _emit_progress_event("create_workstream", ws, { progress_error = _emit_progress_event(tool_name, wp, {
"topic_id": topic_id, "topic_id": topic_id,
"workstream_id": ws["id"], "workplan_id": wp["id"],
"event_type": "workstream_created", "workstream_id": wp["id"],
"summary": f"Workstream created: {title}", "event_type": "workplan_created",
"summary": f"Workplan created: {title}",
"author": "custodian", "author": "custodian",
"detail": {"owner": owner, "slug": slug}, "detail": {"owner": owner, "slug": slug, "repo_id": repo_id},
}) })
if progress_error: if progress_error:
return _json_result(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() @mcp.tool()
def create_task( def create_task(
workstream_id: str, workplan_id: str | None = None,
title: str, workstream_id: str | None = None,
title: str = "",
priority: str = "medium", priority: str = "medium",
description: str | None = None, description: str | None = None,
assignee: str | None = None, assignee: str | None = None,
@@ -525,15 +646,19 @@ def create_task(
"""Create a new task and emit a progress_event. """Create a new task and emit a progress_event.
Args: 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 title: task title
priority: low | medium | high | critical priority: low | medium | high | critical
description: optional longer description description: optional longer description
assignee: optional assignee name assignee: optional assignee name
due_date: optional ISO date string (YYYY-MM-DD) 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", { task = _post("/tasks", {
"workstream_id": workstream_id, "workplan_id": parent_id,
"title": title, "title": title,
"priority": priority, "priority": priority,
"description": description, "description": description,
@@ -544,7 +669,8 @@ def create_task(
return _json_result(error) return _json_result(error)
progress_error = _emit_progress_event("create_task", task, { progress_error = _emit_progress_event("create_task", task, {
"workstream_id": workstream_id, "workplan_id": parent_id,
"workstream_id": parent_id,
"task_id": task["id"], "task_id": task["id"],
"event_type": "task_created", "event_type": "task_created",
"summary": f"Task created: {title}", "summary": f"Task created: {title}",
@@ -865,27 +991,81 @@ def add_progress_event(
@mcp.tool() @mcp.tool()
def update_workstream_status(workstream_id: str, status: str) -> str: def list_workplans(
"""Update a workstream's status. 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: Args:
workstream_id: UUID of the workstream workplan_id: UUID of the workplan
status: proposed | ready | active | blocked | backlog | finished | archived status: proposed | ready | active | blocked | backlog | finished | archived
""" """
ws = _patch(f"/workstreams/{workstream_id}", {"status": status}) return _update_workplan_status_impl(workplan_id, status, tool_name="update_workplan_status")
if error := _response_error("update_workstream_status", ws, ("id", "title")):
return _json_result(error)
progress_error = _emit_progress_event("update_workstream_status", ws, {
"workstream_id": workstream_id, @mcp.tool()
"topic_id": ws.get("topic_id"), def update_workstream_status(workstream_id: str, status: str) -> str:
"event_type": "workstream_status_changed", """Legacy alias for update_workplan_status."""
"summary": f"Workstream status → {status}: {ws['title']}", return _update_workplan_status_impl(workstream_id, status, tool_name="update_workstream_status")
"author": "custodian",
})
if progress_error: @mcp.tool()
return _json_result(progress_error) def update_workplan(
return _json_result(ws) 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() @mcp.tool()
@@ -898,32 +1078,16 @@ def update_workstream(
repo_goal_id: str | None = None, repo_goal_id: str | None = None,
status: str | None = None, status: str | None = None,
) -> str: ) -> str:
"""Update fields on an existing workstream. """Legacy alias for update_workplan."""
return _update_workplan_impl(
Args: workstream_id,
workstream_id: UUID of the workstream title=title,
title: new title (optional) description=description,
description: new description (optional) owner=owner,
owner: new owner (optional) due_date=due_date,
due_date: ISO date string YYYY-MM-DD (optional) repo_goal_id=repo_goal_id,
repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear) status=status,
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -951,6 +1115,41 @@ def get_next_steps() -> str:
# Dependency graph tools (S1.4) # 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() @mcp.tool()
def create_dependency( def create_dependency(
from_workstream_id: str, from_workstream_id: str,
@@ -959,25 +1158,14 @@ def create_dependency(
relationship_type: str = "blocks", relationship_type: str = "blocks",
description: str | None = None, description: str | None = None,
) -> str: ) -> str:
"""Record that one workstream depends on another workstream or task. """Legacy alias for create_workplan_dependency."""
return _create_dependency_impl(
Semantics: from_workstream cannot fully proceed until the target reaches from_workplan_id=from_workstream_id,
a satisfactory state. Provide exactly one of to_workstream_id or to_task_id. to_workplan_id=to_workstream_id,
to_task_id=to_task_id,
Args: relationship_type=relationship_type,
from_workstream_id: UUID of the workstream that has the dependency description=description,
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)
@mcp.tool() @mcp.tool()
@@ -990,9 +1178,15 @@ def list_dependencies(workstream_id: str) -> str:
Args: Args:
workstream_id: UUID of the workstream to inspect workstream_id: UUID of the workstream to inspect
""" """
edges = _get(f"/workstreams/{workstream_id}/dependencies") edges = _get(f"/workplans/{workstream_id}/dependencies")
depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id] depends_on = [
blocks = [e for e in edges if e.get("to_workstream_id") == workstream_id] 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) return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)
@@ -1227,13 +1421,48 @@ def archive_domain(slug: str) -> str:
@mcp.tool() @mcp.tool()
def list_domain_repos(domain_slug: str) -> str: def list_domain_repos(
"""List all repositories registered under a domain. 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: Args:
domain_slug: Domain slug to filter by 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() @mcp.tool()
@@ -1275,6 +1504,60 @@ def register_repo(
return json.dumps(repo, indent=2) 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() @mcp.tool()
def update_repo_path(repo_slug: str, path: str, host: str | None = None) -> str: 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. """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-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-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-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: Usage:
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] 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 1 — automation error: API unreachable, repo list fetch failed, C-00 on
any repo, or other infrastructure fault that prevented a full run 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. 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 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, normalize_workstream_status as _normalize_workstream_status,
ready_review_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.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402
from api.task_status import ( # noqa: E402 from api.task_status import ( # noqa: E402
CANONICAL_TASK_STATUSES, 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) repo_dir = Path(repo_path)
workplans_dir = repo_dir / "workplans" 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 # C-01: workplans/ directory missing
if not workplans_dir.is_dir(): 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, "body": body,
"repo_id": repo_id, "repo_id": repo_id,
"domain": file_domain, "domain": file_domain,
"repo_market_domain": repo_market_domain,
}, },
) )
continue continue
@@ -1708,6 +1740,7 @@ def fix_repo(
wp_file = Path(ctx["wp_file"]) wp_file = Path(ctx["wp_file"])
meta = ctx["meta"] meta = ctx["meta"]
domain = ctx["domain"] domain = ctx["domain"]
repo_market_domain = str(ctx.get("repo_market_domain") or "").strip()
repo_id_val = ctx["repo_id"] repo_id_val = ctx["repo_id"]
body = ctx.get("body", "") body = ctx.get("body", "")
wp_id = str(meta.get("id", "")).strip() wp_id = str(meta.get("id", "")).strip()
@@ -1717,17 +1750,23 @@ def fix_repo(
if status not in VALID_WP_STATUSES: if status not in VALID_WP_STATUSES:
status = "active" 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") topics = _api_get(api_base, "/topics")
topic_id = None topic_id = None
if isinstance(topics, list): if isinstance(topics, list):
for t in topics: for t in topics:
if t.get("domain_slug") == domain: if t.get("domain_slug") == topic_domain:
topic_id = t["id"] topic_id = t["id"]
break break
if topic_id is None: if topic_id is None:
report.fixes_applied.append( 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 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: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac yield ac
app.dependency_overrides.clear() 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() return r.json()
async def _create_workstream(client, topic_id): from tests.conftest import create_test_repo, create_test_workplan
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()
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={ 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 assert r.status_code == 201, r.text
task = r.json() 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 # Make scripts/ importable without installing
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from api.classification import resolve_topic_domain_slug
from consistency_check import ( from consistency_check import (
ConsistencyReport, ConsistencyReport,
Issue, Issue,
@@ -54,6 +55,15 @@ from api.workplan_status import ready_review_status
# for backward compat; their canonical implementations live in repo_sync.py. # 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 # parse_frontmatter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -372,7 +382,7 @@ class TestRenderText:
r.add(severity="WARN", check_id="C-04", message="w") r.add(severity="WARN", check_id="C-04", message="w")
r.add(severity="INFO", check_id="C-08", message="i") r.add(severity="INFO", check_id="C-08", message="i")
text = render_text(r) text = render_text(r)
assert "1 fail" in text assert "1 assessment-fail" in text
assert "1 warn" in text assert "1 warn" in text
assert "1 info" in text assert "1 info" in text
@@ -443,7 +453,7 @@ class TestReportToDict:
r = ConsistencyReport(repo_slug="r", repo_path="/p") r = ConsistencyReport(repo_slug="r", repo_path="/p")
d = report_to_dict(r) d = report_to_dict(r)
assert d["result"] == "pass" 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"] == [] assert d["issues"] == []
def test_fail_result(self): 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() 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={ r = await client.post("/workplans/", json={
"repo_id": repo["id"],
"topic_id": topic_id, "topic_id": topic_id,
"slug": slug, "slug": slug,
"title": title, "title": title,

View File

@@ -28,12 +28,14 @@ async def _create_topic(client, domain_slug="mcp-domain"):
return r.json() return r.json()
async def _create_workstream(client, topic_id): from tests.conftest import create_test_repo, create_test_workplan
r = await client.post("/workstreams/", json={
"topic_id": topic_id, "slug": "mcp-ws", "title": "MCP Workstream",
}) async def _create_workstream(client, topic_id, domain_slug="mcp-domain"):
assert r.status_code == 201 repo = await create_test_repo(client, domain_slug=domain_slug, slug="mcp-repo")
return r.json() 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: 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]]] = [] calls: list[tuple[str, dict[str, Any]]] = []
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body)) calls.append((path, body))
if path == "/workstreams": if path == "/workplans":
return { return {
"id": "ws-1", "id": "wp-1",
"topic_id": body["topic_id"], "repo_id": body["repo_id"],
"topic_id": body.get("topic_id"),
"title": body["title"], "title": body["title"],
"slug": body["slug"], "slug": body["slug"],
"status": body["status"], "status": body["status"],
@@ -39,20 +40,42 @@ class TestMCPWriteTools:
monkeypatch.setattr(server, "_post", fake_post) monkeypatch.setattr(server, "_post", fake_post)
body = await _call_tool( body = await _call_tool(
"create_workstream", "create_workplan",
{"topic_id": "topic-1", "title": "MCP Reliable Write"}, {"repo_id": "repo-1", "topic_id": "topic-1", "title": "MCP Reliable Write"},
) )
assert body == { assert body == {
"id": "ws-1", "id": "wp-1",
"repo_id": "repo-1",
"topic_id": "topic-1", "topic_id": "topic-1",
"title": "MCP Reliable Write", "title": "MCP Reliable Write",
"slug": "mcp-reliable-write", "slug": "mcp-reliable-write",
"status": "active", "status": "active",
} }
assert [path for path, _ in calls] == ["/workstreams", "/progress"] assert [path for path, _ in calls] == ["/workplans", "/progress"]
assert calls[1][1]["workstream_id"] == "ws-1" assert calls[1][1]["workplan_id"] == "wp-1"
assert calls[1][1]["event_type"] == "workstream_created" 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): async def test_create_task_returns_rest_shape_and_emits_progress(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = [] calls: list[tuple[str, dict[str, Any]]] = []
@@ -62,7 +85,8 @@ class TestMCPWriteTools:
if path == "/tasks": if path == "/tasks":
return { return {
"id": "task-1", "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"], "title": body["title"],
"priority": body["priority"], "priority": body["priority"],
"status": "todo", "status": "todo",
@@ -80,6 +104,7 @@ class TestMCPWriteTools:
assert body == { assert body == {
"id": "task-1", "id": "task-1",
"workplan_id": "ws-1",
"workstream_id": "ws-1", "workstream_id": "ws-1",
"title": "MCP task", "title": "MCP task",
"priority": "high", "priority": "high",
@@ -266,18 +291,18 @@ class TestMCPWriteTools:
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body)) calls.append((path, body))
return {"error": "API 422: invalid topic"} return {"error": "API 422: invalid repo"}
monkeypatch.setattr(server, "_post", fake_post) monkeypatch.setattr(server, "_post", fake_post)
body = await _call_tool( body = await _call_tool(
"create_workstream", "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["tool"] == "create_workstream"
assert body["error"] == "API 422: invalid topic" assert body["error"] == "API 422: invalid repo"
assert [path for path, _ in calls] == ["/workstreams"] assert [path for path, _ in calls] == ["/workplans"]
async def test_record_decision_missing_id_is_clear_and_skips_progress(self, monkeypatch): async def test_record_decision_missing_id_is_clear_and_skips_progress(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = [] 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() return response.json()
async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream"): from tests.conftest import create_test_repo, create_test_workplan
response = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": title})
assert response.status_code == 201, response.text
return response.json()
async def _create_task(client, workstream_id, title="Digest task"): async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream", domain_slug="digest"):
response = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title}) 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 assert response.status_code == 201, response.text
return response.json() return response.json()
@@ -277,12 +281,16 @@ class TestRecentlyOnScopeRoutes:
await _create_domain(client, slug="broken", name="Broken Domain") await _create_domain(client, slug="broken", name="Broken Domain")
broken_topic = await _create_topic(client, domain_slug="broken", slug="broken-topic") 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_task(client, broken_workstream["id"], title="Broken source")
await _create_domain(client, slug="good", name="Good Domain") await _create_domain(client, slug="good", name="Good Domain")
good_topic = await _create_topic(client, domain_slug="good", slug="good-topic") 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") await _create_task(client, good_workstream["id"], title="Good source")
response = await client.post("/recently-on-scope/hourly", json={"range": "1d"}) 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() 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 = { 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) payload.update(extra)
r = await client.post("/workstreams/", json=payload) r = await client.post("/workplans/", json=payload)
assert r.status_code == 201, r.text assert r.status_code == 201, r.text
return r.json() 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={ 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 assert r.status_code == 201, r.text
return r.json() return r.json()

View File

@@ -16,19 +16,20 @@ async def _create_topic(client, domain_slug: str = "bulk-domain"):
return r.json() return r.json()
async def _create_workstream(client, topic_id: str): from tests.conftest import create_test_repo, create_test_workplan
r = await client.post(
"/workstreams/",
json={"topic_id": topic_id, "slug": "bulk-ws", "title": "Bulk Workstream"}, 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( r = await client.post(
"/tasks/", "/tasks/",
json={"workstream_id": workstream_id, "title": title}, json={"workplan_id": workplan_id, "title": title},
) )
assert r.status_code == 201 assert r.status_code == 201
return r.json() return r.json()

View File

@@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="testdomain"):
return r.json() return r.json()
async def _create_workstream(client, topic_id, slug="ws1"): from tests.conftest import create_test_repo, create_test_workplan
r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": "WS"})
assert r.status_code == 201, r.text
return r.json()
async def _create_task(client, workstream_id): async def _create_workstream(client, topic_id, slug="ws1", domain_slug="testdomain"):
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": "task"}) 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 assert r.status_code == 201, r.text
return r.json() return r.json()

View File

@@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="td"):
return r.json() return r.json()
async def _create_workstream(client, topic_id): from tests.conftest import create_test_repo, create_test_workplan
r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": "ws", "title": "WS"})
assert r.status_code == 201, r.text
return r.json()
async def _create_task(client, workstream_id, title="my task"): async def _create_workstream(client, topic_id, domain_slug="td"):
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title}) 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 assert r.status_code == 201, r.text
return r.json() return r.json()

View File

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