diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 7c2a645..a7a02e1 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -1,8 +1,15 @@ ## Architecture - +State Hub uses a **repo-anchored classification spine** (STATE-WP-0065): + +- **Primary anchor:** `managed_repos` + committed `.repo-classification.yaml` +- **Market domain:** derived from classification (`domain` field) — 14 fixed values +- **Workplans:** table `workplans`, `repo_id` required, `topic_id` optional +- **Legacy:** `/workstreams/` REST routes and `workstream_*` MCP tools are aliases + +Classification canon lives in `the-custodian/canon/standards/`. +Validation: `api/classification.py` · registration: `make register-from-classification`. ## Quick Reference -`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference +`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference \ No newline at end of file diff --git a/.claude/rules/repo-identity.md b/.claude/rules/repo-identity.md index cea5898..017398a 100644 --- a/.claude/rules/repo-identity.md +++ b/.claude/rules/repo-identity.md @@ -1,5 +1,8 @@ **Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs. -**Domain:** custodian +**Classification:** tooling · infotech (see `.repo-classification.yaml`) **Repo slug:** state-hub -**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a +**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a *(legacy optional tag)* + +Coordination spine is **repo-anchored** — workplans bind to `repo_id`, market +domain is derived from classification, not from the old coordination-domain model. diff --git a/.claude/rules/session-protocol.md b/.claude/rules/session-protocol.md index d5fa7d7..180456c 100644 --- a/.claude/rules/session-protocol.md +++ b/.claude/rules/session-protocol.md @@ -43,7 +43,7 @@ For each file with `status: ready`, `active`, or `blocked`, note pending **Step 4 — Present brief** -1. **Active workstreams** for `custodian` — title, task counts, blocking decisions +1. **Active workplans** for this repo — title, task counts, blocking decisions 2. **Pending tasks** from `workplans/` + any `[repo:state-hub]` hub tasks 3. **Goal guidance** — if `goal_guidance` in summary: - `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"* @@ -51,12 +51,13 @@ For each file with `status: ready`, `active`, or `blocked`, note pending 4. **Suggested next action** — highest-priority open item 5. **SBOM status** — flag if `last_sbom_at` is unset for this repo -If no workstreams: follow First Session Protocol (`first-session.md`). +If no workplans: follow First Session Protocol (`first-session.md`). **During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()` -> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`) +> State Hub is a *read model*. Bootstrap tools (`create_workplan`, `create_task`) > are First Session Protocol only. Work structure belongs in repo files (ADR-001). +> Repo registration uses `.repo-classification.yaml` via `register_repo_from_classification`. **Session close:** With MCP tools: diff --git a/Makefile b/Makefile index 6283b01..aec86d7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile +.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env PYTHON ?= python3 @@ -322,5 +322,20 @@ remove-hooks: gitea-inventory: $(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json) +## Register/update one repo from .repo-classification.yaml: +## make register-from-classification REPO=state-hub +## make register-from-classification PATH=/path/to/repo +## Optional: DRY_RUN=1 +register-from-classification: + @test -n "$(REPO)" -o -n "$(PATH)" || (echo "ERROR: REPO or PATH is required."; exit 1) + $(UV) run python scripts/register_from_classification.py \ + $(if $(PATH),--repo-path "$(PATH)",--slug "$(REPO)") \ + $(if $(DRY_RUN),--dry-run,) + +## Bulk register/update all active repos with accessible local paths +register-from-classification-all: + $(UV) run python scripts/register_from_classification.py --bulk \ + $(if $(DRY_RUN),--dry-run,) + clean: $(COMPOSE) down -v diff --git a/README.md b/README.md index 5112c32..46e21ee 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,9 @@ custodian register-project # register cwd as a Custodian project | `make db` | Start postgres container | | `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) | | `make migrate` | `alembic upgrade head` | -| `make seed` | Insert 6 canonical topics | +| `make seed` | Insert 6 canonical topics (legacy bootstrap) | +| `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` | +| `make register-from-classification-all` | Bulk reclassify all repos with classification files | | `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) | | `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` | | `make dashboard-check` | Build the Observable dashboard as a smoke/regression check | @@ -125,28 +127,30 @@ custodian register-project # register cwd as a Custodian project ## Database Schema -Five tables in dependency order: +Repo-anchored coordination spine (STATE-WP-0065): ``` -topics -└── workstreams - └── tasks (self-FK: parent_task_id) +domains (14 market domains: infotech, financials, communication, …) +managed_repos (classification: category, domain, capability_tags, business_stake, …) +└── workplans (repo_id required; topic_id optional legacy tag) + └── tasks └── progress_events -decisions (FK: topic_id, workstream_id — at least one required) - └── progress_events +topics (optional cross-repo tag; domain_id → market domain) +decisions (FK: topic_id and/or workplan_id) ``` -### Enums +Each registered repo carries a committed `.repo-classification.yaml` (canon +standard v1.0). Registration and reclassification use +`make register-from-classification`. -| Enum | Values | +### Key enums / vocabularies + +| Field | Values | |------|--------| -| `topic_status` | `active` · `paused` · `archived` | -| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` | +| `workplan_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` | | `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` | -| `task_priority` | `low` · `medium` · `high` · `critical` | -| `decision_type` | `made` · `pending` | -| `decision_status` | `open` · `resolved` · `escalated` · `superseded` | -| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` | +| `repo category` | `experimental` · `research` · `project` · `tooling` · `product` · `business` | +| `market domain` | 14 fixed slugs — see `the-custodian/canon/standards/repo-classification.allowed.yaml` | ### Governance constraints encoded in schema @@ -226,9 +230,11 @@ See `mcp_server/TOOLS.md` for the full tool reference card (30 lines, faster tha **Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress` -**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `update_workstream_status` +**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `create_workplan` · `update_workplan_status` · `register_repo_from_classification` -**Resources**: `state://summary` · `state://topics` · `state://workstreams/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked` +**Resources**: `state://summary` · `state://topics` · `state://workplans/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked` + +Legacy `workstream_*` tool names remain as aliases — see `mcp_server/TOOLS.md`. --- diff --git a/SCOPE.md b/SCOPE.md index fb3e22a..6737a7e 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -2,9 +2,9 @@ ## One-Liner -State Hub is the local-first coordination service for Custodian workstreams, -tasks, decisions, progress events, repo metadata, MCP tooling, and dashboard -telemetry. +State Hub is the local-first coordination service for repo-anchored workplans, +tasks, decisions, progress events, repo classification and metadata, MCP +tooling, and dashboard telemetry. ## In Scope @@ -12,7 +12,8 @@ telemetry. - PostgreSQL schema and Alembic migrations - FastMCP server and tool reference - Observable dashboard -- repo registration and consistency synchronization +- repo registration (classification-driven) and consistency synchronization +- repo classification spine (14 market domains, `.repo-classification.yaml`) - task-flow engine and flow definitions - SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking - State Hub tests, operational docs, policies, prompts, and local infra diff --git a/api/classification.py b/api/classification.py new file mode 100644 index 0000000..cefa59b --- /dev/null +++ b/api/classification.py @@ -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) \ No newline at end of file diff --git a/api/models/__init__.py b/api/models/__init__.py index 6c9c3fc..1a1b1a6 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -4,6 +4,8 @@ from api.models.domain_goal import DomainGoal, DomainGoalStatus from api.models.topic import Topic, TopicStatus from api.models.managed_repo import ManagedRepo from api.models.repo_goal import RepoGoal, RepoGoalStatus +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency from api.models.workstream import Workstream from api.models.workstream_dependency import WorkstreamDependency from api.models.task import Task, TaskStatus, TaskPriority @@ -39,6 +41,8 @@ __all__ = [ "Topic", "TopicStatus", "ManagedRepo", "RepoGoal", "RepoGoalStatus", + "Workplan", + "WorkplanDependency", "Workstream", "WorkstreamDependency", "Task", "TaskStatus", "TaskPriority", @@ -61,4 +65,4 @@ __all__ = [ "WorkplanLaunchRequest", "FabricGraphImport", "FabricGraphNode", "FabricGraphEdge", "LegacyInterface", "LegacyInterfaceUsageBucket", -] +] \ No newline at end of file diff --git a/api/models/capability_request.py b/api/models/capability_request.py index d30d8f7..8117375 100644 --- a/api/models/capability_request.py +++ b/api/models/capability_request.py @@ -31,9 +31,9 @@ class CapabilityRequest(Base, TimestampMixin): nullable=False, index=True, ) - requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column( + requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), - ForeignKey("workstreams.id", ondelete="SET NULL"), + ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, ) requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False) @@ -45,9 +45,9 @@ class CapabilityRequest(Base, TimestampMixin): nullable=True, index=True, ) - fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column( + fulfilling_workplan_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), - ForeignKey("workstreams.id", ondelete="SET NULL"), + ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, ) fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True) diff --git a/api/models/contribution.py b/api/models/contribution.py index f6d731f..46b934e 100644 --- a/api/models/contribution.py +++ b/api/models/contribution.py @@ -47,8 +47,8 @@ class Contribution(Base, TimestampMixin): related_topic_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True ) - related_workstream_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + related_workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True ) repo_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True @@ -62,5 +62,5 @@ class Contribution(Base, TimestampMixin): notes: Mapped[str | None] = mapped_column(Text, nullable=True) topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 - workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821 repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 diff --git a/api/models/decision.py b/api/models/decision.py index de807d4..08ae0ed 100644 --- a/api/models/decision.py +++ b/api/models/decision.py @@ -25,8 +25,8 @@ class Decision(Base, TimestampMixin): __tablename__ = "decisions" __table_args__ = ( CheckConstraint( - "topic_id IS NOT NULL OR workstream_id IS NOT NULL", - name="ck_decisions_topic_or_workstream", + "topic_id IS NOT NULL OR workplan_id IS NOT NULL", + name="ck_decisions_topic_or_workplan", ), ) @@ -36,8 +36,8 @@ class Decision(Base, TimestampMixin): topic_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True ) - workstream_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -57,7 +57,7 @@ class Decision(Base, TimestampMixin): ) topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821 - workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821 + workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="decisions") # noqa: F821 progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 "ProgressEvent", back_populates="decision", lazy="selectin" ) diff --git a/api/models/extension_point.py b/api/models/extension_point.py index f34dcbb..84f6c2a 100644 --- a/api/models/extension_point.py +++ b/api/models/extension_point.py @@ -44,13 +44,13 @@ class ExtensionPoint(Base, TimestampMixin): topic_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True ) - workstream_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True ) domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 - workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821 @property def domain_slug(self) -> str: diff --git a/api/models/managed_repo.py b/api/models/managed_repo.py index 9e1f1ed..edf4034 100644 --- a/api/models/managed_repo.py +++ b/api/models/managed_repo.py @@ -1,8 +1,8 @@ import uuid -from datetime import datetime +from datetime import date, datetime -from sqlalchemy import DateTime, ForeignKey, String, Text -from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy import Date, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from api.models.base import Base, TimestampMixin, new_uuid @@ -36,6 +36,15 @@ class ManagedRepo(Base, TimestampMixin): DateTime(timezone=True), nullable=True ) + category: Mapped[str | None] = mapped_column(String(50), nullable=True) + secondary_domains: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True) + capability_tags: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True) + business_stake: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True) + business_mechanics: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True) + classified_at: Mapped[date | None] = mapped_column(Date, nullable=True) + classified_by: Mapped[str | None] = mapped_column(String(50), nullable=True) + standard_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + domain: Mapped["Domain"] = relationship( # noqa: F821 "Domain", back_populates="repos", lazy="selectin" ) diff --git a/api/models/progress_event.py b/api/models/progress_event.py index f52b8b5..ca11ffd 100644 --- a/api/models/progress_event.py +++ b/api/models/progress_event.py @@ -19,8 +19,8 @@ class ProgressEvent(Base): topic_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True ) - workstream_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True ) task_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True @@ -38,6 +38,6 @@ class ProgressEvent(Base): ) topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821 - workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821 + workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="progress_events") # noqa: F821 task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821 decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821 diff --git a/api/models/repo_goal.py b/api/models/repo_goal.py index 743a832..352d5bd 100644 --- a/api/models/repo_goal.py +++ b/api/models/repo_goal.py @@ -40,8 +40,8 @@ class RepoGoal(Base, TimestampMixin): domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821 "DomainGoal", back_populates="repo_goals", lazy="selectin" ) - workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821 - "Workstream", back_populates="repo_goal", lazy="selectin" + workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821 + "Workplan", back_populates="repo_goal", lazy="selectin" ) @property diff --git a/api/models/task.py b/api/models/task.py index 4295e3e..ecdb35b 100644 --- a/api/models/task.py +++ b/api/models/task.py @@ -30,8 +30,8 @@ class Task(Base, TimestampMixin): id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=new_uuid ) - workstream_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True + workplan_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -50,7 +50,7 @@ class Task(Base, TimestampMixin): UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True ) - workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821 + workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821 subtasks: Mapped[list["Task"]] = relationship( "Task", foreign_keys=[parent_task_id], lazy="selectin" ) diff --git a/api/models/technical_debt.py b/api/models/technical_debt.py index 9a64260..d501f60 100644 --- a/api/models/technical_debt.py +++ b/api/models/technical_debt.py @@ -76,13 +76,13 @@ class TechnicalDebt(Base, TimestampMixin): topic_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True ) - workstream_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True ) domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 - workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821 notes: Mapped[list["TDNote"]] = relationship( "TDNote", back_populates="td", lazy="selectin", order_by="TDNote.created_at", diff --git a/api/models/token_event.py b/api/models/token_event.py index 9396d13..cc85825 100644 --- a/api/models/token_event.py +++ b/api/models/token_event.py @@ -27,8 +27,8 @@ class TokenEvent(Base): task_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True ) - workstream_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True, index=True + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, index=True ) repo_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True @@ -75,5 +75,5 @@ class TokenEvent(Base): ) task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821 - workstream: Mapped["Workstream | None"] = relationship("Workstream", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821 repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 diff --git a/api/models/topic.py b/api/models/topic.py index 6a35073..907a6cd 100644 --- a/api/models/topic.py +++ b/api/models/topic.py @@ -36,8 +36,8 @@ class Topic(Base, TimestampMixin): domain: Mapped["Domain"] = relationship( # noqa: F821 "Domain", back_populates="topics", lazy="selectin" ) - workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821 - "Workstream", back_populates="topic", lazy="selectin" + workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821 + "Workplan", back_populates="topic", lazy="selectin" ) decisions: Mapped[list["Decision"]] = relationship( # noqa: F821 "Decision", back_populates="topic", lazy="selectin" diff --git a/api/models/workplan.py b/api/models/workplan.py new file mode 100644 index 0000000..7accd52 --- /dev/null +++ b/api/models/workplan.py @@ -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" + ) \ No newline at end of file diff --git a/api/models/workplan_dependency.py b/api/models/workplan_dependency.py new file mode 100644 index 0000000..4ec5391 --- /dev/null +++ b/api/models/workplan_dependency.py @@ -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 \ No newline at end of file diff --git a/api/models/workplan_launch_request.py b/api/models/workplan_launch_request.py index 72b770e..dec994d 100644 --- a/api/models/workplan_launch_request.py +++ b/api/models/workplan_launch_request.py @@ -13,9 +13,9 @@ class WorkplanLaunchRequest(Base, TimestampMixin): id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=new_uuid ) - workstream_id: Mapped[uuid.UUID] = mapped_column( + workplan_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("workstreams.id", ondelete="CASCADE"), + ForeignKey("workplans.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -36,4 +36,4 @@ class WorkplanLaunchRequest(Base, TimestampMixin): notes: Mapped[str | None] = mapped_column(Text, nullable=True) request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") - workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="launch_requests") # noqa: F821 + workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="launch_requests") # noqa: F821 diff --git a/api/models/workstream.py b/api/models/workstream.py index bf38be1..fb26864 100644 --- a/api/models/workstream.py +++ b/api/models/workstream.py @@ -1,70 +1,6 @@ -import uuid -from datetime import date, datetime +"""Backward-compatibility shim — prefer ``api.models.workplan``.""" +from api.models.workplan import Workplan -from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship +Workstream = Workplan -from api.models.base import Base, TimestampMixin, new_uuid - - -class Workstream(Base, TimestampMixin): - __tablename__ = "workstreams" - - id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), primary_key=True, default=new_uuid - ) - topic_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True - ) - slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) - title: Mapped[str] = mapped_column(String(255), nullable=False) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - status: Mapped[str] = mapped_column( - String(20), nullable=False, default="active", server_default="active" - ) - owner: Mapped[str | None] = mapped_column(String(100), nullable=True) - due_date: Mapped[date | None] = mapped_column(Date, nullable=True) - planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) - planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) - execution_state: Mapped[str] = mapped_column( - String(20), nullable=False, default="manual", server_default="manual", index=True - ) - launch_mode: Mapped[str] = mapped_column( - String(20), nullable=False, default="manual", server_default="manual", index=True - ) - concurrency_mode: Mapped[str] = mapped_column( - String(20), nullable=False, default="sequential", server_default="sequential", index=True - ) - queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) - execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) - scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) - - repo_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), - ForeignKey("managed_repos.id", ondelete="SET NULL"), - nullable=True, - index=True, - ) - repo_goal_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), - ForeignKey("repo_goals.id", ondelete="SET NULL"), - nullable=True, - index=True, - ) - - topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821 - repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 - repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workstreams", lazy="selectin") # noqa: F821 - tasks: Mapped[list["Task"]] = relationship( # noqa: F821 - "Task", back_populates="workstream", lazy="selectin" - ) - decisions: Mapped[list["Decision"]] = relationship( # noqa: F821 - "Decision", back_populates="workstream", lazy="selectin" - ) - progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 - "ProgressEvent", back_populates="workstream", lazy="selectin" - ) - launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821 - "WorkplanLaunchRequest", back_populates="workstream", lazy="selectin" - ) +__all__ = ["Workstream", "Workplan"] \ No newline at end of file diff --git a/api/models/workstream_dependency.py b/api/models/workstream_dependency.py index 31a192e..ac16f6e 100644 --- a/api/models/workstream_dependency.py +++ b/api/models/workstream_dependency.py @@ -1,75 +1,6 @@ -import uuid +"""Backward-compatibility shim — prefer ``api.models.workplan_dependency``.""" +from api.models.workplan_dependency import WorkplanDependency -from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship +WorkstreamDependency = WorkplanDependency -from api.models.base import Base, TimestampMixin, new_uuid - - -class WorkstreamDependency(Base, TimestampMixin): - """Directed dependency edge: `from_workstream` depends on a workstream or task. - - Semantics: the target must reach a satisfactory state before `from_workstream` - can fully proceed. Hard deletes are intentional — - removing an edge removes a constraint, not information. - """ - - __tablename__ = "workstream_dependencies" - __table_args__ = ( - CheckConstraint( - "(to_workstream_id IS NOT NULL AND to_task_id IS NULL) " - "OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)", - name="ck_ws_dep_exactly_one_target", - ), - Index( - "uq_ws_dep_workstream_target", - "from_workstream_id", - "to_workstream_id", - "relationship_type", - unique=True, - postgresql_where=text("to_workstream_id IS NOT NULL"), - ), - Index( - "uq_ws_dep_task_target", - "from_workstream_id", - "to_task_id", - "relationship_type", - unique=True, - postgresql_where=text("to_task_id IS NOT NULL"), - ), - ) - - id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), primary_key=True, default=new_uuid - ) - from_workstream_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), - ForeignKey("workstreams.id", ondelete="CASCADE"), - nullable=False, - index=True, - ) - to_workstream_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), - ForeignKey("workstreams.id", ondelete="CASCADE"), - nullable=True, - index=True, - ) - to_task_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), - ForeignKey("tasks.id", ondelete="CASCADE"), - nullable=True, - index=True, - ) - relationship_type: Mapped[str] = mapped_column( - String(40), nullable=False, default="blocks", server_default="blocks", index=True - ) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - - from_workstream: Mapped["Workstream"] = relationship( # noqa: F821 - "Workstream", foreign_keys=[from_workstream_id] - ) - to_workstream: Mapped["Workstream | None"] = relationship( # noqa: F821 - "Workstream", foreign_keys=[to_workstream_id] - ) - to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821 +__all__ = ["WorkstreamDependency", "WorkplanDependency"] \ No newline at end of file diff --git a/api/routers/capability_requests.py b/api/routers/capability_requests.py index bdacad7..7ab9547 100644 --- a/api/routers/capability_requests.py +++ b/api/routers/capability_requests.py @@ -68,7 +68,7 @@ async def create_request( priority=body.priority, requesting_domain_id=req_domain.id, requesting_agent=body.requesting_agent, - requesting_workstream_id=body.requesting_workstream_id, + requesting_workplan_id=body.requesting_workplan_id, blocking_task_id=body.blocking_task_id, fulfilling_domain_id=fulfilling_domain_id, catalog_entry_id=catalog_entry_id, @@ -115,7 +115,7 @@ async def accept_request( now = datetime.now(tz=timezone.utc) req.status = "accepted" req.fulfilling_agent = body.fulfilling_agent - req.fulfilling_workstream_id = body.fulfilling_workstream_id + req.fulfilling_workplan_id = body.fulfilling_workplan_id req.accepted_at = now # If no fulfilling domain was set by routing, infer from the accepting agent's context @@ -212,7 +212,7 @@ async def patch_request( session: AsyncSession = Depends(get_session), ) -> CapabilityRequest: """Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain), - priority, blocking_task_id, fulfilling_workstream_id. + priority, blocking_task_id, fulfilling_workplan_id. Only fields present in the request body (non-None) are updated. """ req = await _get_request_or_404(request_id, session) @@ -241,9 +241,9 @@ async def patch_request( req.blocking_task_id = body.blocking_task_id corrections.append(f"blocking_task_id → {body.blocking_task_id}") - if body.fulfilling_workstream_id is not None: - req.fulfilling_workstream_id = body.fulfilling_workstream_id - corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}") + if body.fulfilling_workplan_id is not None: + req.fulfilling_workplan_id = body.fulfilling_workplan_id + corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}") if not corrections: return req # no-op diff --git a/api/routers/contributions.py b/api/routers/contributions.py index 7427ee2..bd29422 100644 --- a/api/routers/contributions.py +++ b/api/routers/contributions.py @@ -43,7 +43,7 @@ async def create_contribution( title=body.title, body_path=body.body_path, related_topic_id=body.related_topic_id, - related_workstream_id=body.related_workstream_id, + related_workplan_id=body.related_workplan_id, notes=body.notes, status=ContributionStatus.draft, ) diff --git a/api/routers/decisions.py b/api/routers/decisions.py index 6ee0af6..2f31abb 100644 --- a/api/routers/decisions.py +++ b/api/routers/decisions.py @@ -40,6 +40,7 @@ def _needs_escalation(body: DecisionCreate) -> str | None: @router.get("/", response_model=list[DecisionRead]) async def list_decisions( topic_id: uuid.UUID | None = None, + workplan_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None, status: DecisionStatus | None = None, decision_type: DecisionType | None = None, @@ -48,8 +49,9 @@ async def list_decisions( q = select(Decision) if topic_id: q = q.where(Decision.topic_id == topic_id) - if workstream_id: - q = q.where(Decision.workstream_id == workstream_id) + scope_id = workplan_id or workstream_id + if scope_id: + q = q.where(Decision.workplan_id == scope_id) if status: q = q.where(Decision.status == status) if decision_type: @@ -139,7 +141,7 @@ async def resolve_decision_action( event = ProgressEvent( topic_id=decision.topic_id, - workstream_id=decision.workstream_id, + workplan_id=decision.workplan_id, decision_id=decision.id, event_type="decision_resolved", summary=f"Decision resolved: {decision.title}", @@ -159,7 +161,7 @@ async def resolve_decision_action( "decision_id": str(decision.id), "title": decision.title, "topic_id": str(decision.topic_id) if decision.topic_id else None, - "workstream_id": str(decision.workstream_id) if decision.workstream_id else None, + "workstream_id": str(decision.workplan_id) if decision.workplan_id else None, "decided_by": body.decided_by, "rationale_snippet": (body.rationale or "")[:240], }, diff --git a/api/routers/domains.py b/api/routers/domains.py index f837b04..3f12669 100644 --- a/api/routers/domains.py +++ b/api/routers/domains.py @@ -8,7 +8,7 @@ from api.models.extension_point import ExtensionPoint from api.models.managed_repo import ManagedRepo from api.models.technical_debt import TechnicalDebt from api.models.topic import Topic -from api.models.workstream import Workstream +from api.models.workplan import Workplan from api.schemas.domain import ( DomainCreate, DomainDetail, @@ -32,9 +32,9 @@ async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainD workstream_count = 0 if topic_ids: workstream_count_row = await session.execute( - select(func.count()).select_from(Workstream) - .where(Workstream.topic_id.in_(topic_ids)) - .where(Workstream.status == "active") + select(func.count()).select_from(Workplan) + .where(Workplan.topic_id.in_(topic_ids)) + .where(Workplan.status == "active") ) workstream_count = workstream_count_row.scalar_one() diff --git a/api/routers/execution.py b/api/routers/execution.py index d5da137..0d02d44 100644 --- a/api/routers/execution.py +++ b/api/routers/execution.py @@ -7,8 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.task import Task, TaskStatus from api.models.workplan_launch_request import WorkplanLaunchRequest -from api.models.workstream import Workstream -from api.models.workstream_dependency import WorkstreamDependency +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency from api.schemas.execution import ( ExecutionIntentRead, ExecutionIntentUpdate, @@ -25,10 +25,10 @@ from api.services.execution_queue import ( STATE_HUB_RESPONSIBILITIES, execution_state_for_launch, queue_sort_key, - workstream_blockers, + workplan_blockers, ) from api.routers.workstreams import _legacy_key, _meter_legacy_route -from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status +from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status router = APIRouter(prefix="/execution", tags=["execution"]) @@ -50,7 +50,7 @@ async def _update_execution_intent( body: ExecutionIntentUpdate, session: AsyncSession, ) -> ExecutionIntentRead: - ws = await session.get(Workstream, workstream_id) + ws = await session.get(Workplan, workstream_id) if ws is None: raise HTTPException(status_code=404, detail="Workplan not found") @@ -94,22 +94,22 @@ async def workplan_stack( include_blocked: bool = Query(True), session: AsyncSession = Depends(get_session), ) -> list[WorkplanQueueItem]: - result = await session.execute(select(Workstream)) + result = await session.execute(select(Workplan)) workstreams = [ ws for ws in result.scalars().all() - if normalize_workstream_status(ws.status) not in CLOSED_WORKSTREAM_STATUSES + if normalize_workplan_status(ws.status) not in CLOSED_WORKPLAN_STATUSES ] ws_by_id = {ws.id: ws for ws in workstreams} - ws_status = {ws.id: normalize_workstream_status(ws.status) for ws in workstreams} + ws_status = {ws.id: normalize_workplan_status(ws.status) for ws in workstreams} - dep_result = await session.execute(select(WorkstreamDependency)) + dep_result = await session.execute(select(WorkplanDependency)) ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {} task_deps: dict[uuid.UUID, list[uuid.UUID]] = {} for dep in dep_result.scalars().all(): - if dep.to_workstream_id is not None: - ws_deps.setdefault(dep.from_workstream_id, []).append(dep.to_workstream_id) + if dep.to_workplan_id is not None: + ws_deps.setdefault(dep.from_workplan_id, []).append(dep.to_workplan_id) if dep.to_task_id is not None: - task_deps.setdefault(dep.from_workstream_id, []).append(dep.to_task_id) + task_deps.setdefault(dep.from_workplan_id, []).append(dep.to_task_id) task_ids = [task_id for ids in task_deps.values() for task_id in ids] task_status: dict[uuid.UUID, str] = {} @@ -121,9 +121,9 @@ async def workplan_stack( for ws in workstreams: if not include_manual and ws.execution_state == "manual": continue - lifecycle_status = normalize_workstream_status(ws.status) + lifecycle_status = normalize_workplan_status(ws.status) blocked_ws = [ - blocker for blocker in workstream_blockers(ws.id, ws_deps, ws_status) + blocker for blocker in workplan_blockers(ws.id, ws_deps, ws_status) if blocker in ws_by_id or blocker in ws_status ] blocked_tasks = [ @@ -135,7 +135,7 @@ async def workplan_stack( continue sort_key = queue_sort_key(ws, eligible=eligible) items.append(WorkplanQueueItem( - workstream_id=ws.id, + workplan_id=ws.id, slug=ws.slug, title=ws.title, status=lifecycle_status, @@ -149,7 +149,7 @@ async def workplan_stack( execution_group=ws.execution_group, scheduled_for=ws.scheduled_for, eligible=eligible, - blocked_by_workstream_ids=blocked_ws, + blocked_by_workplan_ids=blocked_ws, blocked_by_task_ids=blocked_tasks, sort_key=sort_key, )) @@ -165,12 +165,12 @@ async def create_launch_request( body: LaunchRequestCreate, session: AsyncSession = Depends(get_session), ) -> WorkplanLaunchRequest: - ws = await session.get(Workstream, body.workstream_id) + ws = await session.get(Workplan, body.workplan_id) if ws is None: - raise HTTPException(status_code=404, detail="Workstream not found") + raise HTTPException(status_code=404, detail="Workplan not found") launch_request = WorkplanLaunchRequest( - workstream_id=ws.id, + workplan_id=ws.id, requested_by=body.requested_by, requested_actor=body.requested_actor, launch_mode=body.launch_mode, @@ -199,16 +199,16 @@ async def list_launch_requests( ) -> list[WorkplanLaunchRequest]: q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc()) if workstream_id: - q = q.where(WorkplanLaunchRequest.workstream_id == workstream_id) + q = q.where(WorkplanLaunchRequest.workplan_id == workstream_id) if request_status: q = q.where(WorkplanLaunchRequest.status == request_status) result = await session.execute(q) return list(result.scalars().all()) -def _intent_read(ws: Workstream) -> ExecutionIntentRead: +def _intent_read(ws: Workplan) -> ExecutionIntentRead: return ExecutionIntentRead( - workstream_id=ws.id, + workplan_id=ws.id, execution_state=ws.execution_state, launch_mode=ws.launch_mode, concurrency_mode=ws.concurrency_mode, diff --git a/api/routers/flows.py b/api/routers/flows.py index 8fdfeee..c017e3c 100644 --- a/api/routers/flows.py +++ b/api/routers/flows.py @@ -17,10 +17,10 @@ from api.flow_defs import ( from api.models.capability_request import CapabilityRequest from api.models.contribution import Contribution from api.models.task import Task, TaskStatus -from api.models.workstream import Workstream -from api.models.workstream_dependency import WorkstreamDependency -from api.services.lifecycle import transition_task_status, transition_workstream_status -from api.workplan_status import normalize_workstream_status +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency +from api.services.lifecycle import transition_task_status, transition_workplan_status +from api.workplan_status import normalize_workplan_status router = APIRouter(prefix="/flows", tags=["flows"]) @@ -94,9 +94,9 @@ async def advance_workstation( entity = await _entity(entity_type, entity_id, session) if entity_type == "workstream": - transition_workstream_status(entity, target_workstation) + transition_workplan_status(entity, target_workstation) elif entity_type == "task": - parent = await session.get(Workstream, entity.workstream_id) + parent = await session.get(Workplan, entity.workplan_id) transition_task_status( entity, target_workstation, @@ -117,7 +117,7 @@ async def _flow_object( ) -> dict[str, Any]: entity = await _entity(entity_type, entity_id, session) status = _value(entity.status) - current_status = normalize_workstream_status(status) if entity_type == "workstream" else status + current_status = normalize_workplan_status(status) if entity_type == "workstream" else status obj: dict[str, Any] = { "id": str(entity.id), "status": current_status, @@ -127,21 +127,21 @@ async def _flow_object( if entity_type == "workstream": tasks = list((await session.execute( - select(Task).where(Task.workstream_id == entity_id) + select(Task).where(Task.workplan_id == entity_id) )).scalars().all()) deps = list((await session.execute( - select(WorkstreamDependency).where( - WorkstreamDependency.from_workstream_id == entity_id + select(WorkplanDependency).where( + WorkplanDependency.from_workplan_id == entity_id ) )).scalars().all()) - dependency_ids = [dep.to_workstream_id for dep in deps] + dependency_ids = [dep.to_workplan_id for dep in deps] dependency_workstations: list[dict[str, Any]] = [] if dependency_ids: dep_ws = list((await session.execute( - select(Workstream).where(Workstream.id.in_(dependency_ids)) + select(Workplan).where(Workplan.id.in_(dependency_ids)) )).scalars().all()) dependency_workstations = [ - {"id": str(ws.id), "workstation": normalize_workstream_status(ws.status)} + {"id": str(ws.id), "workstation": normalize_workplan_status(ws.status)} for ws in dep_ws ] obj.update({ @@ -163,7 +163,7 @@ async def _entity( session: AsyncSession, ): model_by_type = { - "workstream": Workstream, + "workstream": Workplan, "task": Task, "contribution": Contribution, "capability_request": CapabilityRequest, diff --git a/api/routers/reconciliation.py b/api/routers/reconciliation.py index 3c9b3d2..b953769 100644 --- a/api/routers/reconciliation.py +++ b/api/routers/reconciliation.py @@ -9,23 +9,23 @@ from api.models.agent_message import AgentMessage from api.models.managed_repo import ManagedRepo from api.models.task import Task from api.models.task import TaskStatus -from api.models.workstream import Workstream +from api.models.workplan import Workplan from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse from api.services.lifecycle import ( should_activate_parent_for_task_start, status_value, transition_task_status, - transition_workstream_status, + transition_workplan_status, ) from api.task_status import TERMINAL_TASK_STATUSES from api.services.reconciliation import ( ReconciliationClass, StateChangeClassification, classify_task_status_change, - classify_workstream_status_change, + classify_workplan_status_change, ) from api.services.workplan_files import ( - find_workplan_for_workstream, + find_workplan_for_workplan, patch_task_status, patch_workplan_status, resolve_repo_path, @@ -33,7 +33,7 @@ from api.services.workplan_files import ( task_block_linked, workplan_status, ) -from api.workplan_status import normalize_workstream_status +from api.workplan_status import normalize_workplan_status router = APIRouter(prefix="/reconciliation", tags=["reconciliation"]) @@ -51,7 +51,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification: async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool: - result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id)) + result = await session.execute(select(Task.status).where(Task.workplan_id == workstream_id)) statuses = [status_value(row[0]) for row in result.all()] return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses) @@ -98,13 +98,13 @@ async def classify_state_change( session: AsyncSession = Depends(get_session), ) -> StateChangeResponse: if body.target_type == "workstream": - ws = await session.get(Workstream, body.target_id) + ws = await session.get(Workplan, body.target_id) if ws is None: - raise HTTPException(status_code=404, detail="Workstream not found") + raise HTTPException(status_code=404, detail="Workplan not found") repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None repo_path = resolve_repo_path(repo) - workplan_ref = find_workplan_for_workstream(repo, ws.id) if repo_path else None + workplan_ref = find_workplan_for_workplan(repo, ws.id) if repo_path else None actual_file_backed = workplan_ref is not None actual_archived_file = bool(workplan_ref and workplan_ref.archived) file_backed = ( @@ -122,9 +122,9 @@ async def classify_state_change( if body.tasks_terminal is not None else await _workstream_tasks_terminal(session, ws.id) ) - current_status = normalize_workstream_status(ws.status) - target_status = normalize_workstream_status(body.target_status) - classification = classify_workstream_status_change( + current_status = normalize_workplan_status(ws.status) + target_status = normalize_workplan_status(body.target_status) + classification = classify_workplan_status_change( current_status=current_status, target_status=target_status, file_backed=file_backed, @@ -136,7 +136,7 @@ async def classify_state_change( conflict = False if body.apply: expected_status = ( - normalize_workstream_status(body.expected_current_status) + normalize_workplan_status(body.expected_current_status) if body.expected_current_status is not None else None ) @@ -153,7 +153,7 @@ async def classify_state_change( ) conflict = True elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref: - file_status = normalize_workstream_status(workplan_status(workplan_ref.path)) + file_status = normalize_workplan_status(workplan_status(workplan_ref.path)) if file_status and file_status != current_status: classification = _conflict( f"workplan file status {file_status!r} differs from cached DB status {current_status!r}", @@ -163,7 +163,7 @@ async def classify_state_change( else: try: patch_workplan_status(workplan_ref.path, target_status) - patched_status = normalize_workstream_status(workplan_status(workplan_ref.path)) + patched_status = normalize_workplan_status(workplan_status(workplan_ref.path)) except OSError as exc: classification = _conflict( f"workplan file write failed: {exc}", @@ -178,7 +178,7 @@ async def classify_state_change( ) conflict = True else: - transition_workstream_status(ws, target_status) + transition_workplan_status(ws, target_status) await session.commit() write_result = "applied" @@ -221,10 +221,10 @@ async def classify_state_change( if task is None: raise HTTPException(status_code=404, detail="Task not found") - ws = await session.get(Workstream, task.workstream_id) + ws = await session.get(Workplan, task.workplan_id) repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None repo_path = resolve_repo_path(repo) - workplan_ref = find_workplan_for_workstream(repo, ws.id) if ws and repo_path else None + workplan_ref = find_workplan_for_workplan(repo, ws.id) if ws and repo_path else None actual_file_backed = workplan_ref is not None actual_archived_file = bool(workplan_ref and workplan_ref.archived) file_backed = ( @@ -291,7 +291,7 @@ async def classify_state_change( parent_will_activate = should_activate_parent_for_task_start( previous_task_status=current_status, new_task_status=target_status, - parent_workstream_status=ws.status if ws else None, + parent_workplan_status=ws.status if ws else None, ) try: original_text = workplan_ref.path.read_text(encoding="utf-8") @@ -299,7 +299,7 @@ async def classify_state_change( patched_status = status_value(task_block_status(workplan_ref.path, task.id)) if parent_will_activate: patch_workplan_status(workplan_ref.path, "active") - parent_status = normalize_workstream_status(workplan_status(workplan_ref.path)) + parent_status = normalize_workplan_status(workplan_status(workplan_ref.path)) if parent_status != "active": if original_text is not None: workplan_ref.path.write_text(original_text, encoding="utf-8") diff --git a/api/routers/repos.py b/api/routers/repos.py index cccfada..812d0c2 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -9,9 +9,10 @@ import uuid from datetime import datetime, timezone from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import case, func, select +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import case, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import noload from api.config import settings from api.database import get_session @@ -29,11 +30,11 @@ from api.models.managed_repo import ManagedRepo from api.models.repo_goal import RepoGoal from api.models.tpsc import TPSCSnapshot from api.models.task import Task -from api.models.workstream import Workstream +from api.models.workplan import Workplan from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry from api.schemas.managed_repo import ( DispatchTask, - DispatchWorkstream, + DispatchWorkplan, PendingInterfaceChange, RepoCreate, RepoDispatch, @@ -44,6 +45,8 @@ from api.schemas.managed_repo import ( RepoScopeHealth, RepoUpdate, ScopeIssueDetail, + classification_fields_set, + validate_repo_classification_fields, ) from hub_core.routers.repos import create_repos_router @@ -76,13 +79,107 @@ def _core_repo_router(**route_flags) -> APIRouter: repo_read_schema=RepoRead, repo_path_register_schema=RepoPathRegister, list_noload_fields=("goals",), - create_extension_fields=("topic_id",), + create_extension_fields=( + "topic_id", + "category", + "secondary_domains", + "capability_tags", + "business_stake", + "business_mechanics", + "classified_at", + "classified_by", + "standard_version", + ), after_register=_publish_repo_registered, **route_flags, ) -router.include_router(_core_repo_router(include_slug_routes=False)) +router.include_router( + _core_repo_router(include_collection_routes=False, include_slug_routes=False) +) + + +@router.get("/", response_model=list[RepoRead]) +async def list_repos( + response: Response, + domain: str | None = None, + category: str | None = None, + capability_tag: str | None = None, + business_stake: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[ManagedRepo]: + """List repos with optional domain and classification filters.""" + response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" + q = ( + select(ManagedRepo) + .options(noload(ManagedRepo.goals)) + .order_by(ManagedRepo.name) + ) + if domain: + domain_result = await session.execute(select(Domain).where(Domain.slug == domain)) + domain_obj = domain_result.scalar_one_or_none() + if domain_obj is None: + raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found") + q = q.where( + or_( + ManagedRepo.domain_id == domain_obj.id, + ManagedRepo.secondary_domains.contains([domain]), + ) + ) + if category: + q = q.where(ManagedRepo.category == category) + if capability_tag: + q = q.where(ManagedRepo.capability_tags.contains([capability_tag])) + if business_stake: + q = q.where(ManagedRepo.business_stake.contains([business_stake])) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED) +async def register_repo( + body: RepoCreate, + session: AsyncSession = Depends(get_session), +) -> ManagedRepo: + domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug)) + domain_obj = domain_result.scalar_one_or_none() + if domain_obj is None: + raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found") + existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists") + + payload = body.model_dump() + validate_repo_classification_fields( + domain_slug=body.domain_slug, + fields=payload, + require_complete=classification_fields_set(payload), + ) + repo = ManagedRepo( + domain_id=domain_obj.id, + slug=body.slug, + name=body.name, + local_path=body.local_path, + host_paths=body.host_paths, + remote_url=body.remote_url, + git_fingerprint=body.git_fingerprint, + description=body.description, + topic_id=body.topic_id, + category=body.category, + secondary_domains=body.secondary_domains, + capability_tags=body.capability_tags, + business_stake=body.business_stake, + business_mechanics=body.business_mechanics, + classified_at=body.classified_at, + classified_by=body.classified_by, + standard_version=body.standard_version, + ) + session.add(repo) + await session.commit() + await session.refresh(repo) + await _publish_repo_registered(repo, body, domain_obj) + return repo @router.post("/onboard", response_model=RepoOnboardResult) @@ -428,6 +525,38 @@ async def list_repo_scope_health( return entries +@router.patch("/{slug}", response_model=RepoRead) +async def update_repo_with_classification( + slug: str, + body: RepoUpdate, + session: AsyncSession = Depends(get_session), +) -> ManagedRepo: + """Patch repo metadata including classification spine fields.""" + repo = await _get_repo_by_slug(slug, session) + payload = body.model_dump(exclude_unset=True) + domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id)) + domain_obj = domain_result.scalar_one_or_none() + domain_slug = domain_obj.slug if domain_obj else "" + if classification_fields_set(payload): + merged = { + "category": payload.get("category", repo.category), + "secondary_domains": payload.get("secondary_domains", repo.secondary_domains), + "capability_tags": payload.get("capability_tags", repo.capability_tags), + "business_stake": payload.get("business_stake", repo.business_stake), + "business_mechanics": payload.get("business_mechanics", repo.business_mechanics), + } + validate_repo_classification_fields( + domain_slug=domain_slug, + fields=merged, + require_complete=True, + ) + for field, value in payload.items(): + setattr(repo, field, value) + await session.commit() + await session.refresh(repo) + return repo + + router.include_router( _core_repo_router( include_collection_routes=False, @@ -480,19 +609,19 @@ async def get_repo_dispatch( # Active workstreams ws_result = await session.execute( - select(Workstream) - .where(Workstream.repo_id == repo.id, Workstream.status == "active") - .order_by(Workstream.created_at) + select(Workplan) + .where(Workplan.repo_id == repo.id, Workplan.status == "active") + .order_by(Workplan.created_at) ) workstreams = list(ws_result.scalars().all()) - dispatch_workstreams: list[DispatchWorkstream] = [] + dispatch_workstreams: list[DispatchWorkplan] = [] all_interventions: list[DispatchTask] = [] for ws in workstreams: task_result = await session.execute( select(Task) - .where(Task.workstream_id == ws.id, Task.status.in_(["todo", "progress"])) + .where(Task.workplan_id == ws.id, Task.status.in_(["todo", "progress"])) .order_by(Task.created_at) ) tasks = list(task_result.scalars().all()) @@ -511,7 +640,7 @@ async def get_repo_dispatch( all_interventions.extend(interventions) dispatch_workstreams.append( - DispatchWorkstream( + DispatchWorkplan( id=ws.id, title=ws.title, status=ws.status, @@ -554,7 +683,7 @@ async def get_repo_dispatch( return RepoDispatch( repo_slug=slug, active_goal=active_goal, - active_workstreams=dispatch_workstreams, + active_workplans=dispatch_workstreams, human_interventions=all_interventions, pending_interface_changes=pending_changes, scope_needs_review=scope_needs_review, diff --git a/api/routers/state.py b/api/routers/state.py index 6d2a1c1..a29abaf 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -21,8 +21,8 @@ from api.models.sbom_snapshot import SBOMSnapshot from api.models.task import Task, TaskPriority, TaskStatus from api.models.technical_debt import TechnicalDebt from api.models.topic import Topic, TopicStatus -from api.models.workstream import Workstream -from api.models.workstream_dependency import WorkstreamDependency +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency from api.schemas.decision import DecisionRead from api.schemas.domain import DomainSummary from api.schemas.progress_event import ProgressEventRead @@ -45,9 +45,9 @@ from api.schemas.workstream_dependency import WorkstreamDepStub from api.routers.workstreams import _workplan_index from api.task_status import TERMINAL_TASK_STATUSES, status_value from api.workplan_status import ( - CLOSED_WORKSTREAM_STATUSES, - OPEN_WORKSTREAM_STATUSES, - normalize_workstream_status, + CLOSED_WORKPLAN_STATUSES, + OPEN_WORKPLAN_STATUSES, + normalize_workplan_status, ) from task_flow_engine import FlowEngine @@ -82,7 +82,7 @@ async def get_summary( select(Topic) .options( selectinload(Topic.domain), - noload(Topic.workstreams), + noload(Topic.workplans), noload(Topic.decisions), noload(Topic.progress_events), ) @@ -96,16 +96,16 @@ async def get_summary( if topic_ids: topic_ws_rows = await session.execute( select( - Workstream.topic_id, - Workstream.id, - Workstream.slug, - Workstream.title, - Workstream.status, - Workstream.owner, - Workstream.due_date, + Workplan.topic_id, + Workplan.id, + Workplan.slug, + Workplan.title, + Workplan.status, + Workplan.owner, + Workplan.due_date, ) - .where(Workstream.topic_id.in_(topic_ids)) - .order_by(Workstream.created_at) + .where(Workplan.topic_id.in_(topic_ids)) + .order_by(Workplan.created_at) ) for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows: topic_workstreams.setdefault(topic_id, []).append({ @@ -136,10 +136,10 @@ async def get_summary( recent = list(recent_rows.scalars().all()) open_ws_rows = await session.execute( - select(Workstream) + select(Workplan) .options(noload("*")) - .where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES)) - .order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at) + .where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES)) + .order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at) ) open_ws = list(open_ws_rows.scalars().all()) @@ -147,7 +147,7 @@ async def get_summary( task_per_ws: dict = {} task_statuses_per_ws: dict = {} for ws_id, tstat, cnt in await session.execute( - select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status) + select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status) ): task_per_ws.setdefault(ws_id, {})[tstat] = cnt task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt) @@ -157,9 +157,9 @@ async def get_summary( dep_rows = [] if open_ws_ids: dep_result = await session.execute( - select(WorkstreamDependency).where( - (WorkstreamDependency.from_workstream_id.in_(open_ws_ids)) - | (WorkstreamDependency.to_workstream_id.in_(open_ws_ids)) + select(WorkplanDependency).where( + (WorkplanDependency.from_workplan_id.in_(open_ws_ids)) + | (WorkplanDependency.to_workplan_id.in_(open_ws_ids)) ) ) dep_rows = list(dep_result.scalars().all()) @@ -168,16 +168,16 @@ async def get_summary( dep_ws_ids = set() dep_task_ids = set() for d in dep_rows: - dep_ws_ids.add(d.from_workstream_id) - if d.to_workstream_id: - dep_ws_ids.add(d.to_workstream_id) + dep_ws_ids.add(d.from_workplan_id) + if d.to_workplan_id: + dep_ws_ids.add(d.to_workplan_id) if d.to_task_id: dep_task_ids.add(d.to_task_id) ws_lookup: dict = {w.id: w for w in open_ws} extra_ids = dep_ws_ids - set(ws_lookup.keys()) if extra_ids: extra_rows = await session.execute( - select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids)) + select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids)) ) for w in extra_rows.scalars(): ws_lookup[w.id] = w @@ -189,7 +189,7 @@ async def get_summary( # Index: workstream_id → (depends_on stubs, blocks stubs) dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} for d in dep_rows: - from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id + from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id if from_id in dep_index and to_id and to_id in ws_lookup: dep_index[from_id]["depends_on"].append(WorkstreamDepStub( dep_id=d.id, @@ -230,9 +230,9 @@ async def get_summary( "workstation": w.status, "tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])], "dependencies": [ - {"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)} + {"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)} for d in dep_rows - if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup + if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup ], } flow_result = flow_engine.evaluate(flow_obj, workstream_flow) @@ -246,7 +246,7 @@ async def get_summary( select(Topic.status, func.count()).group_by(Topic.status) )} ws_counts = {r[0]: r[1] for r in await session.execute( - select(Workstream.status, func.count()).group_by(Workstream.status) + select(Workplan.status, func.count()).group_by(Workplan.status) )} task_counts = {r[0]: r[1] for r in await session.execute( select(Task.status, func.count()).group_by(Task.status) @@ -407,7 +407,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: select(Topic) .options( selectinload(Topic.domain), - noload(Topic.workstreams), + noload(Topic.workplans), noload(Topic.decisions), noload(Topic.progress_events), ) @@ -418,12 +418,12 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: topic_map = {topic.id: topic for topic in topics} workstream_rows = await session.execute( - select(Workstream) + select(Workplan) .options(noload("*")) .order_by( - Workstream.planning_priority.asc().nullslast(), - Workstream.planning_order.asc().nullslast(), - Workstream.updated_at.desc(), + Workplan.planning_priority.asc().nullslast(), + Workplan.planning_order.asc().nullslast(), + Workplan.updated_at.desc(), ) ) workstreams_all = list(workstream_rows.scalars().all()) @@ -455,7 +455,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: task_statuses_per_ws: dict = {} task_totals_by_status: dict[str, int] = {} for ws_id, task_status, count in await session.execute( - select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status) + select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status) ): status = status_value(task_status) task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0}) @@ -467,15 +467,15 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: open_ws = [ w for w in workstreams_all - if normalize_workstream_status(w.status) in OPEN_WORKSTREAM_STATUSES + if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES ] open_ws_ids = [w.id for w in open_ws] dep_rows = [] if open_ws_ids: dep_result = await session.execute( - select(WorkstreamDependency).where( - (WorkstreamDependency.from_workstream_id.in_(open_ws_ids)) - | (WorkstreamDependency.to_workstream_id.in_(open_ws_ids)) + select(WorkplanDependency).where( + (WorkplanDependency.from_workplan_id.in_(open_ws_ids)) + | (WorkplanDependency.to_workplan_id.in_(open_ws_ids)) ) ) dep_rows = list(dep_result.scalars().all()) @@ -490,19 +490,19 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: "workstation": w.status, "tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])], "dependencies": [ - {"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)} + {"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)} for d in dep_rows - if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup + if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup ], } flow_result = flow_engine.evaluate(flow_obj, workstream_flow) - effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workstream_status(w.status) + effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workplan_status(w.status) topic_counts = {r[0]: r[1] for r in await session.execute( select(Topic.status, func.count()).group_by(Topic.status) )} ws_counts = {r[0]: r[1] for r in await session.execute( - select(Workstream.status, func.count()).group_by(Workstream.status) + select(Workplan.status, func.count()).group_by(Workplan.status) )} dec_counts = {r[0]: r[1] for r in await session.execute( select(Decision.status, func.count()).group_by(Decision.status) @@ -631,7 +631,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: workplan_rows.append(DashboardWorkplanRow( id=w.id, title=w.title, - status=normalize_workstream_status(w.status), + status=normalize_workplan_status(w.status), domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"), repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"), workplan_filename=workplan.get("filename"), @@ -695,9 +695,9 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: # Active workstream counts per domain (join through topics) ws_per_domain = {} for domain_id, cnt in await session.execute( - select(Topic.domain_id, func.count(Workstream.id)) - .join(Workstream, Workstream.topic_id == Topic.id) - .where(Workstream.status.in_(["active", "blocked"])) + select(Topic.domain_id, func.count(Workplan.id)) + .join(Workplan, Workplan.topic_id == Topic.id) + .where(Workplan.status.in_(["active", "blocked"])) .group_by(Topic.domain_id) ): ws_per_domain[domain_id] = cnt @@ -734,10 +734,10 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr Used by workstreams.md and dependencies.md which only need dep edges. """ open_ws_rows = await session.execute( - select(Workstream) + select(Workplan) .options(noload("*")) - .where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES)) - .order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at) + .where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES)) + .order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at) ) open_ws = list(open_ws_rows.scalars().all()) @@ -745,9 +745,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr dep_rows = [] if open_ws_ids: dep_result = await session.execute( - select(WorkstreamDependency).where( - (WorkstreamDependency.from_workstream_id.in_(open_ws_ids)) - | (WorkstreamDependency.to_workstream_id.in_(open_ws_ids)) + select(WorkplanDependency).where( + (WorkplanDependency.from_workplan_id.in_(open_ws_ids)) + | (WorkplanDependency.to_workplan_id.in_(open_ws_ids)) ) ) dep_rows = list(dep_result.scalars().all()) @@ -755,9 +755,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr dep_ws_ids: set = set() dep_task_ids: set = set() for d in dep_rows: - dep_ws_ids.add(d.from_workstream_id) - if d.to_workstream_id: - dep_ws_ids.add(d.to_workstream_id) + dep_ws_ids.add(d.from_workplan_id) + if d.to_workplan_id: + dep_ws_ids.add(d.to_workplan_id) if d.to_task_id: dep_task_ids.add(d.to_task_id) @@ -765,7 +765,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr extra_ids = dep_ws_ids - set(ws_lookup.keys()) if extra_ids: extra_rows = await session.execute( - select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids)) + select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids)) ) for w in extra_rows.scalars(): ws_lookup[w.id] = w @@ -777,7 +777,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} for d in dep_rows: - from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id + from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id if from_id in dep_index and to_id and to_id in ws_lookup: dep_index[from_id]["depends_on"].append(WorkstreamDepStub( dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type, @@ -831,7 +831,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: .options(noload("*")) .where(Decision.status == DecisionStatus.resolved) .where(Decision.decided_at >= cutoff) - .where(Decision.workstream_id.isnot(None)) + .where(Decision.workplan_id.isnot(None)) .order_by(Decision.decided_at.desc()) .limit(20) ) @@ -839,7 +839,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: open_tasks_rows = await session.execute( select(Task) .options(noload("*")) - .where(Task.workstream_id == decision.workstream_id) + .where(Task.workplan_id == decision.workplan_id) .where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait])) ) open_tasks = list(open_tasks_rows.scalars().all()) @@ -848,7 +848,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at)) if task.id in seen_task_ids: continue - ws = await session.get(Workstream, decision.workstream_id, options=[noload("*")]) + ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")]) domain_slug = await _get_domain_slug_for_workstream(ws, session) steps.append(NextStep( type="resolved_decision", @@ -868,13 +868,13 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: # ── Signal 2: cleared dependencies ────────────────────────────────────── all_dep_rows = await session.execute( select( - WorkstreamDependency.from_workstream_id, - WorkstreamDependency.to_workstream_id, - ).where(WorkstreamDependency.to_workstream_id.isnot(None)) + WorkplanDependency.from_workplan_id, + WorkplanDependency.to_workplan_id, + ).where(WorkplanDependency.to_workplan_id.isnot(None)) ) all_deps = all_dep_rows.all() - # Group from_workstream_id → set of to_workstream_ids + # Group from_workplan_id → set of to_workplan_ids dep_map: dict = {} dep_ws_ids = set() for from_ws_id, to_ws_id in all_deps: @@ -886,12 +886,12 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: if dep_ws_ids: ws_rows = await session.execute( select( - Workstream.id, - Workstream.status, - Workstream.title, - Workstream.slug, - Workstream.topic_id, - ).where(Workstream.id.in_(dep_ws_ids)) + Workplan.id, + Workplan.status, + Workplan.title, + Workplan.slug, + Workplan.topic_id, + ).where(Workplan.id.in_(dep_ws_ids)) ) ws_info = { ws_id: { @@ -906,9 +906,9 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: ready_from_ws_ids = [ from_ws_id for from_ws_id, to_ws_ids in dep_map.items() - if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES + if normalize_workplan_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKPLAN_STATUSES and all( - normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES + normalize_workplan_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKPLAN_STATUSES for to_id in to_ws_ids ) ] @@ -918,11 +918,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: todo_rows = await session.execute( select(Task) .options(noload("*")) - .where(Task.workstream_id.in_(ready_from_ws_ids)) + .where(Task.workplan_id.in_(ready_from_ws_ids)) .where(Task.status == TaskStatus.todo) ) for task in todo_rows.scalars().all(): - todo_by_ws.setdefault(task.workstream_id, []).append(task) + todo_by_ws.setdefault(task.workplan_id, []).append(task) for from_ws_id in ready_from_ws_ids: from_ws = ws_info.get(from_ws_id, {}) @@ -956,7 +956,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: return steps -async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None: +async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None: """Get the domain slug for a workstream via its topic.""" if ws is None or ws.topic_id is None: return None diff --git a/api/routers/tasks.py b/api/routers/tasks.py index 20526a3..6a40afb 100644 --- a/api/routers/tasks.py +++ b/api/routers/tasks.py @@ -9,7 +9,7 @@ from api.database import get_session from api.models.progress_event import ProgressEvent from api.models.task import Task, TaskStatus from api.models.token_event import TokenEvent -from api.models.workstream import Workstream +from api.models.workplan import Workplan from api.schemas.task import ( TaskCountRead, TaskCreate, @@ -26,6 +26,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"]) @router.get("/", response_model=list[TaskRead]) async def list_tasks( + workplan_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None, status: str | None = None, assignee: str | None = None, @@ -37,8 +38,9 @@ async def list_tasks( session: AsyncSession = Depends(get_session), ) -> list[Task]: q = select(Task) - if workstream_id: - q = q.where(Task.workstream_id == workstream_id) + scope_id = workplan_id or workstream_id + if scope_id: + q = q.where(Task.workplan_id == scope_id) if status: q = q.where(Task.status == TaskStatus(normalize_task_status(status))) if assignee: @@ -60,18 +62,20 @@ async def list_tasks( @router.get("/counts", response_model=list[TaskCountRead]) async def count_tasks( + workplan_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None, status: str | None = None, session: AsyncSession = Depends(get_session), ) -> list[TaskCountRead]: - q = select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status) - if workstream_id: - q = q.where(Task.workstream_id == workstream_id) + q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status) + scope_id = workplan_id or workstream_id + if scope_id: + q = q.where(Task.workplan_id == scope_id) if status: q = q.where(Task.status == TaskStatus(normalize_task_status(status))) rows = await session.execute(q) return [ - TaskCountRead(workstream_id=ws_id, status=task_status, count=count) + TaskCountRead(workplan_id=ws_id, status=task_status, count=count) for ws_id, task_status, count in rows ] @@ -84,7 +88,7 @@ async def create_task( task = Task(**body.model_dump()) session.add(task) if status_value(task.status) == "progress": - ws = await session.get(Workstream, task.workstream_id) + ws = await session.get(Workplan, task.workplan_id) transition_task_status( task, task.status, @@ -137,7 +141,7 @@ async def bulk_status_sync( target_status = status_value(update.status) if update.blocking_reason is not None: task.blocking_reason = update.blocking_reason - ws = await session.get(Workstream, task.workstream_id) + ws = await session.get(Workplan, task.workplan_id) transition_task_status( task, update.status, @@ -146,7 +150,7 @@ async def bulk_status_sync( ) event = ProgressEvent( task_id=task.id, - workstream_id=task.workstream_id, + workplan_id=task.workplan_id, event_type="task_status_changed", summary=f"Task status -> {target_status}: {task.title}", author=author, @@ -218,7 +222,7 @@ async def update_task( for field, value in update_data.items(): setattr(task, field, value) if new_status is not None: - ws = await session.get(Workstream, task.workstream_id) + ws = await session.get(Workplan, task.workplan_id) transition_task_status( task, status_update, @@ -247,7 +251,7 @@ async def update_task( elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data: # Tier 2: prorate workplan total across task count count_result = await session.execute( - select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id) + select(func.count(Task.id)).where(Task.workplan_id == task.workplan_id) ) task_count = max(count_result.scalar() or 1, 1) tin = token_data["workplan_tokens_in"] // task_count @@ -273,12 +277,12 @@ async def update_task( raw_metadata = {"estimation_method": "fixed_task_done_fallback"} # Resolve repo_id via workstream - ws = await session.get(Workstream, task.workstream_id) + ws = await session.get(Workplan, task.workplan_id) repo_id = ws.repo_id if ws else None event = TokenEvent( task_id=task_id, - workstream_id=task.workstream_id, + workplan_id=task.workplan_id, repo_id=repo_id, tokens_in=tin, tokens_out=tout, diff --git a/api/routers/token_events.py b/api/routers/token_events.py index b282fda..84ce623 100644 --- a/api/routers/token_events.py +++ b/api/routers/token_events.py @@ -11,7 +11,7 @@ from api.database import get_session from api.models.managed_repo import ManagedRepo from api.models.task import Task from api.models.token_event import TokenEvent -from api.models.workstream import Workstream +from api.models.workplan import Workplan from api.schemas.token_event import ( RepoTokenSummary, TokenAggregateRow, @@ -102,14 +102,14 @@ def _apply_event_defaults(data: dict[str, Any]) -> dict[str, Any]: async def _populate_relationship_defaults(data: dict[str, Any], session: AsyncSession) -> dict[str, Any]: # Auto-populate workstream_id from task if not provided - if data.get("task_id") and not data.get("workstream_id"): + if data.get("task_id") and not data.get("workplan_id"): task = await session.get(Task, data["task_id"]) if task: - data["workstream_id"] = task.workstream_id + data["workplan_id"] = task.workplan_id # Auto-populate repo_id from workstream if not provided - if data.get("workstream_id") and not data.get("repo_id"): - ws = await session.get(Workstream, data["workstream_id"]) + if data.get("workplan_id") and not data.get("repo_id"): + ws = await session.get(Workplan, data["workplan_id"]) if ws and ws.repo_id: data["repo_id"] = ws.repo_id return data @@ -169,7 +169,7 @@ def _filter_query( if task_id: q = q.where(TokenEvent.task_id == task_id) if workstream_id: - q = q.where(TokenEvent.workstream_id == workstream_id) + q = q.where(TokenEvent.workplan_id == workstream_id) if repo_id: q = q.where(TokenEvent.repo_id == repo_id) if ref_type: @@ -195,7 +195,7 @@ def _filter_query( if unattributed: q = q.where( TokenEvent.repo_id.is_(None), - TokenEvent.workstream_id.is_(None), + TokenEvent.workplan_id.is_(None), TokenEvent.task_id.is_(None), ) return q @@ -238,7 +238,7 @@ async def get_token_summary( uid = uuid.UUID(id) except ValueError: raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream") - q = q.where(TokenEvent.workstream_id == uid) + q = q.where(TokenEvent.workplan_id == uid) elif scope == "repo": try: uid = uuid.UUID(id) @@ -297,7 +297,7 @@ async def get_tokens_by_repo( Resolution order for each event: 1. token_events.repo_id (direct) 2. → workstreams.repo_id (via workstream_id) - 3. → task.workstream_id → workstreams.repo_id (via task_id) + 3. → task.workplan_id → workstreams.repo_id (via task_id) Only events that resolve to a repo are included. """ @@ -314,8 +314,8 @@ async def get_tokens_by_repo( ) events = list(events_result.scalars().all()) - ws_result = await session.execute(select(Workstream)) - ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()} + ws_result = await session.execute(select(Workplan)) + ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()} task_result = await session.execute(select(Task)) task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()} @@ -326,9 +326,9 @@ async def get_tokens_by_repo( def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None: if e.repo_id: return e.repo_id - ws_id = e.workstream_id + ws_id = e.workplan_id if not ws_id and e.task_id and e.task_id in task_map: - ws_id = task_map[e.task_id].workstream_id + ws_id = task_map[e.task_id].workplan_id if ws_id and ws_id in ws_map: return ws_map[ws_id].repo_id return None @@ -391,8 +391,8 @@ async def get_token_aggregate( ) events = list(events_result.scalars().all()) - ws_result = await session.execute(select(Workstream)) - ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()} + ws_result = await session.execute(select(Workplan)) + ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()} task_result = await session.execute(select(Task)) task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()} @@ -403,9 +403,9 @@ async def get_token_aggregate( def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None: if e.repo_id: return e.repo_id - ws_id = e.workstream_id + ws_id = e.workplan_id if not ws_id and e.task_id and e.task_id in task_map: - ws_id = task_map[e.task_id].workstream_id + ws_id = task_map[e.task_id].workplan_id if ws_id and ws_id in ws_map: return ws_map[ws_id].repo_id return None @@ -458,7 +458,7 @@ async def get_token_aggregate( repo = repo_map.get(rid) if rid else None add(by_repo, str(rid) if rid else None, repo.slug if repo else None, e) - ws_id = e.workstream_id or (task_map[e.task_id].workstream_id if e.task_id in task_map else None) + ws_id = e.workplan_id or (task_map[e.task_id].workplan_id if e.task_id in task_map else None) ws = ws_map.get(ws_id) if ws_id else None add(by_workstream, str(ws_id) if ws_id else None, ws.title if ws else None, e) @@ -520,7 +520,7 @@ async def get_token_quality( source_counts[(e.measurement_kind, e.source_provider, e.source_id)] += 1 if e.source_provider == "task_fallback" or e.note == "heuristic": fallback_count += 1 - if e.measurement_kind == "measured" and not (e.repo_id or e.workstream_id or e.task_id): + if e.measurement_kind == "measured" and not (e.repo_id or e.workplan_id or e.task_id): unattributed_measured_count += 1 if e.measurement_kind == "measured" and not e.source_id: missing_provenance_count += 1 diff --git a/api/routers/topics.py b/api/routers/topics.py index 4498d08..fd715f5 100644 --- a/api/routers/topics.py +++ b/api/routers/topics.py @@ -30,7 +30,7 @@ async def list_topics( ) -> list[Topic]: response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" q = select(Topic).options( - noload(Topic.workstreams), + noload(Topic.workplans), noload(Topic.decisions), noload(Topic.progress_events), ) diff --git a/api/routers/workstream_dependencies.py b/api/routers/workstream_dependencies.py index 0d553d1..d0a8d0d 100644 --- a/api/routers/workstream_dependencies.py +++ b/api/routers/workstream_dependencies.py @@ -6,9 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.task import Task -from api.models.workstream import Workstream -from api.models.workstream_dependency import WorkstreamDependency -from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency +from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead from api.routers.workstreams import _legacy_key, _meter_legacy_route router = APIRouter(prefix="/workstreams", tags=["dependencies"]) @@ -17,28 +17,28 @@ workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"]) async def _create_dependency( *, - workstream_id: uuid.UUID, - body: WorkstreamDependencyCreate, + workplan_id: uuid.UUID, + body: WorkplanDependencyCreate, session: AsyncSession, -) -> WorkstreamDependency: - if await session.get(Workstream, workstream_id) is None: +) -> WorkplanDependency: + if await session.get(Workplan, workplan_id) is None: raise HTTPException(status_code=404, detail="from workplan not found") - has_workstream_target = body.to_workstream_id is not None + has_workplan_target = body.to_workplan_id is not None has_task_target = body.to_task_id is not None - if has_workstream_target == has_task_target: + if has_workplan_target == has_task_target: raise HTTPException(status_code=422, detail="provide exactly one dependency target") - if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None: + if body.to_workplan_id and await session.get(Workplan, body.to_workplan_id) is None: raise HTTPException(status_code=404, detail="target workplan not found") if body.to_task_id and await session.get(Task, body.to_task_id) is None: raise HTTPException(status_code=404, detail="target task not found") - if workstream_id == body.to_workstream_id: + if workplan_id == body.to_workplan_id: raise HTTPException(status_code=422, detail="a workplan cannot depend on itself") - dep = WorkstreamDependency( - from_workstream_id=workstream_id, - to_workstream_id=body.to_workstream_id, + dep = WorkplanDependency( + from_workplan_id=workplan_id, + to_workplan_id=body.to_workplan_id, to_task_id=body.to_task_id, relationship_type=body.relationship_type, description=body.description, @@ -51,15 +51,15 @@ async def _create_dependency( async def _list_dependencies( *, - workstream_id: uuid.UUID, + workplan_id: uuid.UUID, session: AsyncSession, -) -> list[WorkstreamDependency]: - if await session.get(Workstream, workstream_id) is None: +) -> list[WorkplanDependency]: + if await session.get(Workplan, workplan_id) is None: raise HTTPException(status_code=404, detail="workplan not found") rows = await session.execute( - select(WorkstreamDependency).where( - (WorkstreamDependency.from_workstream_id == workstream_id) - | (WorkstreamDependency.to_workstream_id == workstream_id) + select(WorkplanDependency).where( + (WorkplanDependency.from_workplan_id == workplan_id) + | (WorkplanDependency.to_workplan_id == workplan_id) ) ) return list(rows.scalars().all()) @@ -67,14 +67,14 @@ async def _list_dependencies( async def _delete_dependency( *, - workstream_id: uuid.UUID, + workplan_id: uuid.UUID, dep_id: uuid.UUID, session: AsyncSession, ) -> None: - dep = await session.get(WorkstreamDependency, dep_id) + dep = await session.get(WorkplanDependency, dep_id) if dep is None: raise HTTPException(status_code=404, detail="dependency not found") - if dep.from_workstream_id != workstream_id: + if dep.from_workplan_id != workplan_id: raise HTTPException(status_code=403, detail="dependency does not belong to this workplan") await session.delete(dep) await session.commit() @@ -82,17 +82,17 @@ async def _delete_dependency( @router.post( "/{workstream_id}/dependencies/", - response_model=WorkstreamDependencyRead, + response_model=WorkplanDependencyRead, status_code=status.HTTP_201_CREATED, ) async def create_dependency( request: Request, response: Response, workstream_id: uuid.UUID, - body: WorkstreamDependencyCreate, + body: WorkplanDependencyCreate, session: AsyncSession = Depends(get_session), -) -> WorkstreamDependency: - """Record that workstream_id depends on another workstream or a task.""" +) -> WorkplanDependency: + """Record that workstream_id depends on another workplan or a task.""" await _meter_legacy_route( session=session, request=request, @@ -100,33 +100,33 @@ async def create_dependency( interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"), replacement_ref="/workplans/{workplan_id}/dependencies/", ) - return await _create_dependency(workstream_id=workstream_id, body=body, session=session) + return await _create_dependency(workplan_id=workstream_id, body=body, session=session) @workplan_router.post( "/{workplan_id}/dependencies/", - response_model=WorkstreamDependencyRead, + response_model=WorkplanDependencyRead, status_code=status.HTTP_201_CREATED, ) async def create_workplan_dependency( workplan_id: uuid.UUID, - body: WorkstreamDependencyCreate, + body: WorkplanDependencyCreate, session: AsyncSession = Depends(get_session), -) -> WorkstreamDependency: - return await _create_dependency(workstream_id=workplan_id, body=body, session=session) +) -> WorkplanDependency: + return await _create_dependency(workplan_id=workplan_id, body=body, session=session) @router.get( "/{workstream_id}/dependencies/", - response_model=list[WorkstreamDependencyRead], + response_model=list[WorkplanDependencyRead], ) async def list_dependencies( request: Request, response: Response, workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> list[WorkstreamDependency]: - """Return all dependency edges touching this workstream (both directions).""" +) -> list[WorkplanDependency]: + """Return all dependency edges touching this workplan (both directions).""" await _meter_legacy_route( session=session, request=request, @@ -134,18 +134,18 @@ async def list_dependencies( interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"), replacement_ref="/workplans/{workplan_id}/dependencies/", ) - return await _list_dependencies(workstream_id=workstream_id, session=session) + return await _list_dependencies(workplan_id=workstream_id, session=session) @workplan_router.get( "/{workplan_id}/dependencies/", - response_model=list[WorkstreamDependencyRead], + response_model=list[WorkplanDependencyRead], ) async def list_workplan_dependencies( workplan_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> list[WorkstreamDependency]: - return await _list_dependencies(workstream_id=workplan_id, session=session) +) -> list[WorkplanDependency]: + return await _list_dependencies(workplan_id=workplan_id, session=session) @router.delete( @@ -167,7 +167,7 @@ async def delete_dependency( interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"), replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}", ) - await _delete_dependency(workstream_id=workstream_id, dep_id=dep_id, session=session) + await _delete_dependency(workplan_id=workstream_id, dep_id=dep_id, session=session) @workplan_router.delete( @@ -179,4 +179,4 @@ async def delete_workplan_dependency( dep_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> None: - await _delete_dependency(workstream_id=workplan_id, dep_id=dep_id, session=session) + await _delete_dependency(workplan_id=workplan_id, dep_id=dep_id, session=session) \ No newline at end of file diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index 6e80094..05e6a3e 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -15,21 +15,21 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.events import EventEnvelope, publish_event from api.models.managed_repo import ManagedRepo -from api.models.workstream import Workstream -from api.schemas.workstream import ( - WorkstreamCreate, - WorkstreamRead, - WorkstreamUpdate, +from api.models.workplan import Workplan +from api.schemas.workplan import ( + WorkplanCreate, + WorkplanRead, + WorkplanUpdate, ) -from api.services.lifecycle import transition_workstream_status +from api.services.lifecycle import transition_workplan_status from api.services.legacy_meter import ( LegacyUsageIdentity, identity_from_request, record_legacy_usage, ) from api.workplan_status import ( - is_supported_workstream_status, - normalize_workstream_status, + is_supported_workplan_status, + normalize_workplan_status, ready_review_status, ) @@ -138,7 +138,7 @@ async def _meter_legacy_event( logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True) -async def _list_workstreams( +async def _list_workplans( *, topic_id: uuid.UUID | None, repo_id: uuid.UUID | None, @@ -147,27 +147,27 @@ async def _list_workstreams( owner: str | None, slug: str | None, session: AsyncSession, -) -> list[Workstream]: - q = select(Workstream) +) -> list[Workplan]: + q = select(Workplan) if topic_id: - q = q.where(Workstream.topic_id == topic_id) + q = q.where(Workplan.topic_id == topic_id) if repo_id: - q = q.where(Workstream.repo_id == repo_id) + q = q.where(Workplan.repo_id == repo_id) if repo_goal_id: - q = q.where(Workstream.repo_goal_id == repo_goal_id) + q = q.where(Workplan.repo_goal_id == repo_goal_id) if status_filter: - normalised_status = normalize_workstream_status(status_filter) - if not is_supported_workstream_status(status_filter): + normalised_status = normalize_workplan_status(status_filter) + if not is_supported_workplan_status(status_filter): raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'") - q = q.where(Workstream.status == normalised_status) + q = q.where(Workplan.status == normalised_status) if owner: - q = q.where(Workstream.owner == owner) + q = q.where(Workplan.owner == owner) if slug: - q = q.where(Workstream.slug == slug) + q = q.where(Workplan.slug == slug) q = q.order_by( - Workstream.planning_priority.asc().nullslast(), - Workstream.planning_order.asc().nullslast(), - Workstream.updated_at.desc(), + Workplan.planning_priority.asc().nullslast(), + Workplan.planning_order.asc().nullslast(), + Workplan.updated_at.desc(), ) result = await session.execute(q) return list(result.scalars().all()) @@ -190,10 +190,10 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]: continue for path in sorted(directory.glob("*.md")): data = _frontmatter(path) - workstream_id = data.get("state_hub_workstream_id") - if not workstream_id: + workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id") + if not workplan_id: continue - file_status = normalize_workstream_status(data.get("status", "")) + file_status = normalize_workplan_status(data.get("status", "")) review = ( ready_review_status( root, @@ -203,7 +203,7 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]: if file_status == "ready" else None ) - index[str(workstream_id)] = { + index[str(workplan_id)] = { "filename": path.name, "relative_path": str(path.relative_to(root)), "repo_slug": repo.slug, @@ -287,79 +287,79 @@ async def _workplan_index( return _INDEX_CACHE -async def _create_workstream( +async def _create_workplan( *, - body: WorkstreamCreate, + body: WorkplanCreate, session: AsyncSession, -) -> Workstream: - ws = Workstream(**body.model_dump()) - session.add(ws) +) -> Workplan: + wp = Workplan(**body.model_dump()) + session.add(wp) await session.commit() - await session.refresh(ws) - return ws + await session.refresh(wp) + return wp -async def _get_workstream( +async def _get_workplan( *, - workstream_id: uuid.UUID, + workplan_id: uuid.UUID, session: AsyncSession, -) -> Workstream: - ws = await session.get(Workstream, workstream_id) - if ws is None: +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: raise HTTPException(status_code=404, detail="Workplan not found") - return ws + return wp -async def _update_workstream( +async def _update_workplan( *, - workstream_id: uuid.UUID, - body: WorkstreamUpdate, + workplan_id: uuid.UUID, + body: WorkplanUpdate, session: AsyncSession, -) -> Workstream: - ws = await session.get(Workstream, workstream_id) - if ws is None: +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: raise HTTPException(status_code=404, detail="Workplan not found") update_data = body.model_dump(exclude_unset=True) status_update = update_data.pop("status", None) - prev_status = ws.status + prev_status = wp.status for field, value in update_data.items(): - setattr(ws, field, value) + setattr(wp, field, value) if status_update is not None: - transition_workstream_status(ws, status_update) + transition_workplan_status(wp, status_update) await session.commit() - await session.refresh(ws) + await session.refresh(wp) - if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished": - await _publish_completion_events(ws, session) + if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished": + await _publish_completion_events(wp, session) - return ws + return wp -async def _archive_workstream( +async def _archive_workplan( *, - workstream_id: uuid.UUID, + workplan_id: uuid.UUID, session: AsyncSession, -) -> Workstream: - ws = await session.get(Workstream, workstream_id) - if ws is None: +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: raise HTTPException(status_code=404, detail="Workplan not found") - transition_workstream_status(ws, "archived") + transition_workplan_status(wp, "archived") await session.commit() - await session.refresh(ws) - return ws + await session.refresh(wp) + return wp -async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> None: +async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> None: workplan_envelope = EventEnvelope.new( _COMPLETED_WORKPLAN_EVENT, attributes={ - "workplan_id": str(ws.id), - "legacy_workstream_id": str(ws.id), - "slug": ws.slug, - "title": ws.title, - "topic_id": str(ws.topic_id), - "repo_id": str(ws.repo_id) if ws.repo_id else None, - "repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None, + "workplan_id": str(wp.id), + "legacy_workstream_id": str(wp.id), + "slug": wp.slug, + "title": wp.title, + "topic_id": str(wp.topic_id) if wp.topic_id else None, + "repo_id": str(wp.repo_id) if wp.repo_id else None, + "repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None, }, ) asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope)) @@ -372,18 +372,18 @@ async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> N legacy_envelope = EventEnvelope.new( _COMPLETED_WORKSTREAM_EVENT, attributes={ - "workstream_id": str(ws.id), - "slug": ws.slug, - "title": ws.title, - "topic_id": str(ws.topic_id), - "repo_id": str(ws.repo_id) if ws.repo_id else None, - "repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None, + "workstream_id": str(wp.id), + "slug": wp.slug, + "title": wp.title, + "topic_id": str(wp.topic_id) if wp.topic_id else None, + "repo_id": str(wp.repo_id) if wp.repo_id else None, + "repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None, }, ) asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope)) -@router.get("/", response_model=list[WorkstreamRead]) +@router.get("/", response_model=list[WorkplanRead]) async def list_workstreams( request: Request, response: Response, @@ -394,7 +394,7 @@ async def list_workstreams( owner: str | None = None, slug: str | None = None, session: AsyncSession = Depends(get_session), -) -> list[Workstream]: +) -> list[Workplan]: await _meter_legacy_route( session=session, request=request, @@ -402,7 +402,7 @@ async def list_workstreams( interface_key=_legacy_key("GET", "/workstreams/"), replacement_ref="/workplans/", ) - return await _list_workstreams( + return await _list_workplans( topic_id=topic_id, repo_id=repo_id, repo_goal_id=repo_goal_id, @@ -413,7 +413,7 @@ async def list_workstreams( ) -@workplan_router.get("/", response_model=list[WorkstreamRead]) +@workplan_router.get("/", response_model=list[WorkplanRead]) async def list_workplans( topic_id: uuid.UUID | None = None, repo_id: uuid.UUID | None = None, @@ -422,8 +422,8 @@ async def list_workplans( owner: str | None = None, slug: str | None = None, session: AsyncSession = Depends(get_session), -) -> list[Workstream]: - return await _list_workstreams( +) -> list[Workplan]: + return await _list_workplans( topic_id=topic_id, repo_id=repo_id, repo_goal_id=repo_goal_id, @@ -459,13 +459,13 @@ async def workplan_index_preferred( return await _workplan_index(refresh=refresh, session=session) -@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) +@router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED) async def create_workstream( request: Request, response: Response, - body: WorkstreamCreate, + body: WorkplanCreate, session: AsyncSession = Depends(get_session), -) -> Workstream: +) -> Workplan: await _meter_legacy_route( session=session, request=request, @@ -473,24 +473,24 @@ async def create_workstream( interface_key=_legacy_key("POST", "/workstreams/"), replacement_ref="/workplans/", ) - return await _create_workstream(body=body, session=session) + return await _create_workplan(body=body, session=session) -@workplan_router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) +@workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED) async def create_workplan( - body: WorkstreamCreate, + body: WorkplanCreate, session: AsyncSession = Depends(get_session), -) -> Workstream: - return await _create_workstream(body=body, session=session) +) -> Workplan: + return await _create_workplan(body=body, session=session) -@router.get("/{workstream_id}", response_model=WorkstreamRead) +@router.get("/{workstream_id}", response_model=WorkplanRead) async def get_workstream( request: Request, response: Response, workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> Workstream: +) -> Workplan: await _meter_legacy_route( session=session, request=request, @@ -498,25 +498,25 @@ async def get_workstream( interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"), replacement_ref="/workplans/{workplan_id}", ) - return await _get_workstream(workstream_id=workstream_id, session=session) + return await _get_workplan(workplan_id=workstream_id, session=session) -@workplan_router.get("/{workplan_id}", response_model=WorkstreamRead) +@workplan_router.get("/{workplan_id}", response_model=WorkplanRead) async def get_workplan( workplan_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> Workstream: - return await _get_workstream(workstream_id=workplan_id, session=session) +) -> Workplan: + return await _get_workplan(workplan_id=workplan_id, session=session) -@router.patch("/{workstream_id}", response_model=WorkstreamRead) +@router.patch("/{workstream_id}", response_model=WorkplanRead) async def update_workstream( request: Request, response: Response, workstream_id: uuid.UUID, - body: WorkstreamUpdate, + body: WorkplanUpdate, session: AsyncSession = Depends(get_session), -) -> Workstream: +) -> Workplan: await _meter_legacy_route( session=session, request=request, @@ -524,25 +524,25 @@ async def update_workstream( interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"), replacement_ref="/workplans/{workplan_id}", ) - return await _update_workstream(workstream_id=workstream_id, body=body, session=session) + return await _update_workplan(workplan_id=workstream_id, body=body, session=session) -@workplan_router.patch("/{workplan_id}", response_model=WorkstreamRead) +@workplan_router.patch("/{workplan_id}", response_model=WorkplanRead) async def update_workplan( workplan_id: uuid.UUID, - body: WorkstreamUpdate, + body: WorkplanUpdate, session: AsyncSession = Depends(get_session), -) -> Workstream: - return await _update_workstream(workstream_id=workplan_id, body=body, session=session) +) -> Workplan: + return await _update_workplan(workplan_id=workplan_id, body=body, session=session) -@router.delete("/{workstream_id}", response_model=WorkstreamRead) +@router.delete("/{workstream_id}", response_model=WorkplanRead) async def archive_workstream( request: Request, response: Response, workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> Workstream: +) -> Workplan: await _meter_legacy_route( session=session, request=request, @@ -550,12 +550,12 @@ async def archive_workstream( interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"), replacement_ref="/workplans/{workplan_id}", ) - return await _archive_workstream(workstream_id=workstream_id, session=session) + return await _archive_workplan(workplan_id=workstream_id, session=session) -@workplan_router.delete("/{workplan_id}", response_model=WorkstreamRead) +@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead) async def archive_workplan( workplan_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> Workstream: - return await _archive_workstream(workstream_id=workplan_id, session=session) +) -> Workplan: + return await _archive_workplan(workplan_id=workplan_id, session=session) \ No newline at end of file diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py index 139016d..4556ee8 100644 --- a/api/schemas/__init__.py +++ b/api/schemas/__init__.py @@ -1,4 +1,5 @@ from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams +from api.schemas.workplan import WorkplanCreate, WorkplanUpdate, WorkplanRead from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead from api.schemas.task import TaskCreate, TaskUpdate, TaskRead from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead @@ -9,6 +10,7 @@ from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead __all__ = [ "TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams", + "WorkplanCreate", "WorkplanUpdate", "WorkplanRead", "WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead", "TaskCreate", "TaskUpdate", "TaskRead", "DecisionCreate", "DecisionUpdate", "DecisionRead", diff --git a/api/schemas/capability_request.py b/api/schemas/capability_request.py index 222c5ab..4552a27 100644 --- a/api/schemas/capability_request.py +++ b/api/schemas/capability_request.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field from hub_core.schemas.capability import ( CapabilityRequestDispute, @@ -23,20 +23,29 @@ class CapabilityRequestCreate(BaseModel): priority: str = "medium" requesting_domain: str # slug, resolved to domain_id in router requesting_agent: str - requesting_workstream_id: uuid.UUID | None = None + requesting_workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("requesting_workplan_id", "requesting_workstream_id"), + ) blocking_task_id: uuid.UUID | None = None class CapabilityRequestAccept(BaseModel): fulfilling_agent: str - fulfilling_workstream_id: uuid.UUID | None = None + fulfilling_workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"), + ) class CapabilityRequestPatch(BaseModel): catalog_entry_id: uuid.UUID | None = None priority: str | None = None blocking_task_id: uuid.UUID | None = None - fulfilling_workstream_id: uuid.UUID | None = None + fulfilling_workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"), + ) class CapabilityRequestReroute(BaseModel): @@ -57,10 +66,10 @@ class CapabilityRequestRead(BaseModel): status: str requesting_domain_slug: str requesting_agent: str - requesting_workstream_id: uuid.UUID | None = None + requesting_workplan_id: uuid.UUID | None = None fulfilling_domain_slug: str | None = None fulfilling_agent: str | None = None - fulfilling_workstream_id: uuid.UUID | None = None + fulfilling_workplan_id: uuid.UUID | None = None blocking_task_id: uuid.UUID | None = None catalog_entry_id: uuid.UUID | None = None resolution_note: str | None = None @@ -73,3 +82,13 @@ class CapabilityRequestRead(BaseModel): completed_at: datetime | None = None created_at: datetime updated_at: datetime + + @computed_field # type: ignore[prop-decorator] + @property + def requesting_workstream_id(self) -> uuid.UUID | None: + return self.requesting_workplan_id + + @computed_field # type: ignore[prop-decorator] + @property + def fulfilling_workstream_id(self) -> uuid.UUID | None: + return self.fulfilling_workplan_id diff --git a/api/schemas/compat.py b/api/schemas/compat.py new file mode 100644 index 0000000..4338124 --- /dev/null +++ b/api/schemas/compat.py @@ -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 \ No newline at end of file diff --git a/api/schemas/contribution.py b/api/schemas/contribution.py index a241c09..98a355a 100644 --- a/api/schemas/contribution.py +++ b/api/schemas/contribution.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field from api.models.contribution import ContributionStatus, ContributionType @@ -14,7 +14,10 @@ class ContributionCreate(BaseModel): title: str body_path: str | None = None related_topic_id: uuid.UUID | None = None - related_workstream_id: uuid.UUID | None = None + related_workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("related_workplan_id", "related_workstream_id"), + ) repo_id: uuid.UUID | None = None notes: str | None = None @@ -36,10 +39,15 @@ class ContributionRead(BaseModel): status: ContributionStatus body_path: str | None = None related_topic_id: uuid.UUID | None = None - related_workstream_id: uuid.UUID | None = None + related_workplan_id: uuid.UUID | None = None repo_id: uuid.UUID | None = None submitted_at: datetime | None = None resolved_at: datetime | None = None notes: str | None = None created_at: datetime updated_at: datetime + + @computed_field # type: ignore[prop-decorator] + @property + def related_workstream_id(self) -> uuid.UUID | None: + return self.related_workplan_id diff --git a/api/schemas/decision.py b/api/schemas/decision.py index f02041f..a02cae6 100644 --- a/api/schemas/decision.py +++ b/api/schemas/decision.py @@ -4,11 +4,16 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, model_validator from api.models.decision import DecisionStatus, DecisionType +from api.schemas.compat import OptionalWorkplanIdCompatMixin +from pydantic import AliasChoices, Field class DecisionCreate(BaseModel): topic_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) title: str description: str | None = None decision_type: DecisionType = DecisionType.pending @@ -20,9 +25,9 @@ class DecisionCreate(BaseModel): escalation_note: str | None = None @model_validator(mode="after") - def topic_or_workstream_required(self) -> "DecisionCreate": - if self.topic_id is None and self.workstream_id is None: - raise ValueError("At least one of topic_id or workstream_id must be set") + def topic_or_workplan_required(self) -> "DecisionCreate": + if self.topic_id is None and self.workplan_id is None: + raise ValueError("At least one of topic_id or workplan_id must be set") return self @@ -45,11 +50,10 @@ class DecisionUpdate(BaseModel): superseded_by: uuid.UUID | None = None -class DecisionRead(BaseModel): +class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID topic_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None title: str description: str | None = None decision_type: DecisionType @@ -61,4 +65,4 @@ class DecisionRead(BaseModel): escalation_note: str | None = None superseded_by: uuid.UUID | None = None created_at: datetime - updated_at: datetime + updated_at: datetime \ No newline at end of file diff --git a/api/schemas/execution.py b/api/schemas/execution.py index 60799e6..3e0df39 100644 --- a/api/schemas/execution.py +++ b/api/schemas/execution.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"] @@ -21,7 +21,12 @@ class ExecutionIntentUpdate(BaseModel): class ExecutionIntentRead(BaseModel): - workstream_id: uuid.UUID + workplan_id: uuid.UUID + + @computed_field # type: ignore[prop-decorator] + @property + def workstream_id(self) -> uuid.UUID: + return self.workplan_id execution_state: ExecutionState launch_mode: LaunchMode concurrency_mode: ConcurrencyMode @@ -31,7 +36,7 @@ class ExecutionIntentRead(BaseModel): class WorkplanQueueItem(BaseModel): - workstream_id: uuid.UUID + workplan_id: uuid.UUID slug: str title: str status: str @@ -45,13 +50,18 @@ class WorkplanQueueItem(BaseModel): execution_group: str | None = None scheduled_for: datetime | None = None eligible: bool - blocked_by_workstream_ids: list[uuid.UUID] = Field(default_factory=list) + blocked_by_workplan_ids: list[uuid.UUID] = Field(default_factory=list) + + @computed_field # type: ignore[prop-decorator] + @property + def blocked_by_workstream_ids(self) -> list[uuid.UUID]: + return self.blocked_by_workplan_ids blocked_by_task_ids: list[uuid.UUID] = Field(default_factory=list) sort_key: list[str | int] = Field(default_factory=list) class LaunchRequestCreate(BaseModel): - workstream_id: uuid.UUID + workplan_id: uuid.UUID = Field(validation_alias=AliasChoices("workplan_id", "workstream_id")) requested_by: str = "dashboard" requested_actor: str | None = None launch_mode: LaunchMode = "queued" @@ -67,10 +77,15 @@ class LaunchRequestCreate(BaseModel): class LaunchRequestRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID - workstream_id: uuid.UUID + workplan_id: uuid.UUID requested_by: str requested_actor: str | None = None launch_mode: LaunchMode + + @computed_field # type: ignore[prop-decorator] + @property + def workstream_id(self) -> uuid.UUID: + return self.workplan_id concurrency_mode: ConcurrencyMode priority: str | None = None repo_id: uuid.UUID | None = None diff --git a/api/schemas/extension_point.py b/api/schemas/extension_point.py index 67cc1c1..3820c9a 100644 --- a/api/schemas/extension_point.py +++ b/api/schemas/extension_point.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import AliasChoices, BaseModel, ConfigDict, Field from api.models.extension_point import EPStatus @@ -18,7 +18,10 @@ class EPCreate(BaseModel): status: EPStatus = EPStatus.open priority: str = "medium" topic_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) class EPUpdate(BaseModel): @@ -29,7 +32,10 @@ class EPUpdate(BaseModel): ep_type: str | None = None status: EPStatus | None = None priority: str | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) class EPRead(BaseModel): @@ -45,6 +51,10 @@ class EPRead(BaseModel): status: EPStatus priority: str topic_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None created_at: datetime + + @property + def workstream_id(self) -> uuid.UUID | None: + return self.workplan_id updated_at: datetime diff --git a/api/schemas/managed_repo.py b/api/schemas/managed_repo.py index b6f28ce..82d619e 100644 --- a/api/schemas/managed_repo.py +++ b/api/schemas/managed_repo.py @@ -2,8 +2,10 @@ import uuid from datetime import date, datetime from typing import Any, Literal -from pydantic import BaseModel, Field +from fastapi import HTTPException +from pydantic import BaseModel, Field, model_validator +from api.classification import validate_classification from hub_core.schemas.managed_repo import ( RepoCreate as CoreRepoCreate, RepoPathRegister, @@ -11,11 +13,73 @@ from hub_core.schemas.managed_repo import ( ) -class RepoCreate(CoreRepoCreate): +class ClassificationFields(BaseModel): + category: str | None = None + secondary_domains: list[str] | None = None + capability_tags: list[str] | None = None + business_stake: list[str] | None = None + business_mechanics: list[str] | None = None + classified_at: date | None = None + classified_by: str | None = None + standard_version: str | None = None + + +def classification_fields_set(data: dict[str, Any]) -> bool: + keys = ( + "category", + "secondary_domains", + "capability_tags", + "business_stake", + "business_mechanics", + "classified_at", + "classified_by", + "standard_version", + ) + return any(data.get(key) is not None for key in keys) + + +def validate_repo_classification_fields( + *, + domain_slug: str, + fields: dict[str, Any], + require_complete: bool = False, +) -> dict[str, Any]: + """Validate classification fields and return normalized values for persistence.""" + if not classification_fields_set(fields) and not require_complete: + return fields + + block = { + "category": fields.get("category"), + "domain": domain_slug, + "secondary_domains": fields.get("secondary_domains") or [], + "capability_tags": fields.get("capability_tags") or [], + "business_stake": fields.get("business_stake") or [], + "business_mechanics": fields.get("business_mechanics") or [], + } + if require_complete or fields.get("category") is not None: + if block["category"] is None: + raise HTTPException(status_code=422, detail="`category` is required when classification is provided") + if classification_fields_set(fields) and block["category"] is not None: + errors, warnings = validate_classification(block) + if errors: + raise HTTPException(status_code=422, detail={"classification_errors": errors, "warnings": warnings}) + return fields + + +class RepoCreate(CoreRepoCreate, ClassificationFields): topic_id: uuid.UUID | None = None + @model_validator(mode="after") + def validate_classification_on_create(self) -> "RepoCreate": + validate_repo_classification_fields( + domain_slug=self.domain_slug, + fields=self.model_dump(), + require_complete=classification_fields_set(self.model_dump()), + ) + return self -class RepoUpdate(BaseModel): + +class RepoUpdate(ClassificationFields): name: str | None = None local_path: str | None = None remote_url: str | None = None @@ -42,7 +106,7 @@ class RepoOnboardResult(BaseModel): stderr: str = "" -class RepoRead(CoreRepoRead): +class RepoRead(CoreRepoRead, ClassificationFields): topic_id: uuid.UUID | None = None sbom_source: str | None = None last_sbom_at: datetime | None = None @@ -59,13 +123,17 @@ class DispatchTask(BaseModel): needs_human: bool -class DispatchWorkstream(BaseModel): +class DispatchWorkplan(BaseModel): id: uuid.UUID title: str status: str pending_tasks: list[DispatchTask] +# Legacy alias +DispatchWorkstream = DispatchWorkplan + + class PendingInterfaceChange(BaseModel): id: uuid.UUID title: str @@ -90,13 +158,17 @@ class ScopeIssueDetail(BaseModel): class RepoDispatch(BaseModel): repo_slug: str active_goal: dict[str, Any] | None - active_workstreams: list[DispatchWorkstream] + active_workplans: list[DispatchWorkplan] human_interventions: list[DispatchTask] pending_interface_changes: list[PendingInterfaceChange] scope_needs_review: bool scope_issue_details: list[ScopeIssueDetail] last_state_synced_at: datetime | None + @property + def active_workstreams(self) -> list[DispatchWorkplan]: + return self.active_workplans + class RepoScopeHealth(BaseModel): repo_slug: str @@ -104,4 +176,4 @@ class RepoScopeHealth(BaseModel): local_path: str | None = None path_available: bool scope_needs_review: bool - scope_issue_details: list[ScopeIssueDetail] + scope_issue_details: list[ScopeIssueDetail] \ No newline at end of file diff --git a/api/schemas/progress_event.py b/api/schemas/progress_event.py index 1c16e51..3789aff 100644 --- a/api/schemas/progress_event.py +++ b/api/schemas/progress_event.py @@ -2,12 +2,17 @@ import uuid from datetime import datetime from typing import Any -from pydantic import BaseModel, ConfigDict +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +from api.schemas.compat import OptionalWorkplanIdCompatMixin class ProgressEventCreate(BaseModel): topic_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) task_id: uuid.UUID | None = None decision_id: uuid.UUID | None = None event_type: str @@ -17,11 +22,10 @@ class ProgressEventCreate(BaseModel): session_id: str | None = None -class ProgressEventRead(BaseModel): +class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID topic_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None task_id: uuid.UUID | None = None decision_id: uuid.UUID | None = None event_type: str @@ -29,4 +33,4 @@ class ProgressEventRead(BaseModel): detail: dict[str, Any] | None = None author: str | None = None session_id: str | None = None - created_at: datetime + created_at: datetime \ No newline at end of file diff --git a/api/schemas/task.py b/api/schemas/task.py index 3603788..ef1c608 100644 --- a/api/schemas/task.py +++ b/api/schemas/task.py @@ -5,6 +5,7 @@ from typing import Self from pydantic import BaseModel, ConfigDict, field_validator, model_validator from api.models.task import TaskPriority, TaskStatus +from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin from api.task_status import normalize_task_status @@ -17,8 +18,7 @@ class TaskStatusMixin(BaseModel): return normalize_task_status(value) -class TaskCreate(TaskStatusMixin): - workstream_id: uuid.UUID +class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin): title: str description: str | None = None status: TaskStatus = TaskStatus.todo @@ -96,10 +96,9 @@ class TaskStatusBulkSync(BaseModel): return value -class TaskRead(TaskStatusMixin): +class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin): model_config = ConfigDict(from_attributes=True) id: uuid.UUID - workstream_id: uuid.UUID title: str description: str | None = None status: TaskStatus @@ -114,8 +113,7 @@ class TaskRead(TaskStatusMixin): updated_at: datetime -class TaskCountRead(TaskStatusMixin): - workstream_id: uuid.UUID +class TaskCountRead(TaskStatusMixin, WorkplanIdCompatMixin): status: TaskStatus count: int diff --git a/api/schemas/technical_debt.py b/api/schemas/technical_debt.py index 6926c20..c25cf08 100644 --- a/api/schemas/technical_debt.py +++ b/api/schemas/technical_debt.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import AliasChoices, BaseModel, ConfigDict, Field from api.models.technical_debt import TDStatus @@ -35,7 +35,10 @@ class TDCreate(BaseModel): severity: str = "medium" status: TDStatus = TDStatus.open topic_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) class TDUpdate(BaseModel): @@ -45,7 +48,10 @@ class TDUpdate(BaseModel): debt_type: str | None = None severity: str | None = None status: TDStatus | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) class TDRead(BaseModel): @@ -61,7 +67,11 @@ class TDRead(BaseModel): severity: str status: TDStatus topic_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None created_at: datetime + + @property + def workstream_id(self) -> uuid.UUID | None: + return self.workplan_id updated_at: datetime notes: list[TDNoteRead] = [] diff --git a/api/schemas/token_event.py b/api/schemas/token_event.py index 7c7ca9d..476ed8e 100644 --- a/api/schemas/token_event.py +++ b/api/schemas/token_event.py @@ -2,14 +2,19 @@ import uuid from datetime import datetime from typing import Any -from pydantic import BaseModel, ConfigDict, Field, computed_field +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field + +from api.schemas.compat import OptionalWorkplanIdCompatMixin class TokenEventCreate(BaseModel): tokens_in: int tokens_out: int task_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) repo_id: uuid.UUID | None = None session_id: str | None = None model: str | None = None @@ -32,14 +37,13 @@ class TokenEventCreate(BaseModel): raw_metadata: dict[str, Any] | None = None -class TokenEventRead(BaseModel): +class TokenEventRead(OptionalWorkplanIdCompatMixin, BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID tokens_in: int tokens_out: int task_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None repo_id: uuid.UUID | None = None session_id: str | None = None model: str | None = None @@ -90,7 +94,10 @@ class TokenEventPatch(BaseModel): tokens_in: int | None = None tokens_out: int | None = None task_id: uuid.UUID | None = None - workstream_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) repo_id: uuid.UUID | None = None session_id: str | None = None note: str | None = None diff --git a/api/schemas/workplan.py b/api/schemas/workplan.py new file mode 100644 index 0000000..4d52256 --- /dev/null +++ b/api/schemas/workplan.py @@ -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] = [] \ No newline at end of file diff --git a/api/schemas/workplan_dependency.py b/api/schemas/workplan_dependency.py new file mode 100644 index 0000000..22e325f --- /dev/null +++ b/api/schemas/workplan_dependency.py @@ -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 \ No newline at end of file diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py index da18ebd..3b3d719 100644 --- a/api/schemas/workstream.py +++ b/api/schemas/workstream.py @@ -1,106 +1,41 @@ -import uuid -from datetime import date, datetime -from typing import Literal +"""Legacy aliases — prefer ``api.schemas.workplan``.""" +from api.schemas.workplan import ( + ConcurrencyMode, + ExecutionState, + LaunchMode, + WorkplanCreate, + WorkplanRead, + WorkplanStatus, + WorkplanStatusMixin, + WorkplanUpdate, + WorkplanWithDeps, + WorkplanWithTaskCounts, +) -from pydantic import BaseModel, ConfigDict, field_validator +WorkstreamStatus = WorkplanStatus +WorkstreamStatusMixin = WorkplanStatusMixin +WorkstreamCreate = WorkplanCreate +WorkstreamUpdate = WorkplanUpdate +WorkstreamRead = WorkplanRead +WorkstreamWithTaskCounts = WorkplanWithTaskCounts +WorkstreamWithDeps = WorkplanWithDeps -from api.schemas.workstream_dependency import WorkstreamDepStub -from api.workplan_status import normalize_workstream_status - -WorkstreamStatus = Literal[ - "proposed", - "ready", - "active", - "blocked", - "backlog", - "finished", - "archived", -] -ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"] -LaunchMode = Literal["manual", "queued", "scheduled", "immediate"] -ConcurrencyMode = Literal["sequential", "parallel"] - - -class WorkstreamStatusMixin(BaseModel): - @field_validator("status", mode="before", check_fields=False) - @classmethod - def _normalise_status(cls, value): - return normalize_workstream_status(value) - - -class WorkstreamCreate(WorkstreamStatusMixin): - topic_id: uuid.UUID - slug: str - title: str - description: str | None = None - status: WorkstreamStatus = "active" - owner: str | None = None - due_date: date | None = None - planning_priority: str | None = None - planning_order: int | None = None - execution_state: ExecutionState = "manual" - launch_mode: LaunchMode = "manual" - concurrency_mode: ConcurrencyMode = "sequential" - queue_rank: int | None = None - execution_group: str | None = None - scheduled_for: datetime | None = None - repo_id: uuid.UUID | None = None # GEMS primary: the owning repository - repo_goal_id: uuid.UUID | None = None - - -class WorkstreamUpdate(WorkstreamStatusMixin): - title: str | None = None - description: str | None = None - status: WorkstreamStatus | None = None - owner: str | None = None - due_date: date | None = None - planning_priority: str | None = None - planning_order: int | None = None - execution_state: ExecutionState | None = None - launch_mode: LaunchMode | None = None - concurrency_mode: ConcurrencyMode | None = None - queue_rank: int | None = None - execution_group: str | None = None - scheduled_for: datetime | None = None - repo_id: uuid.UUID | None = None - repo_goal_id: uuid.UUID | None = None - - -class WorkstreamRead(WorkstreamStatusMixin): - model_config = ConfigDict(from_attributes=True) - id: uuid.UUID - topic_id: uuid.UUID - repo_id: uuid.UUID | None = None - repo_goal_id: uuid.UUID | None = None - slug: str - title: str - description: str | None = None - status: WorkstreamStatus - owner: str | None = None - due_date: date | None = None - planning_priority: str | None = None - planning_order: int | None = None - execution_state: ExecutionState = "manual" - launch_mode: LaunchMode = "manual" - concurrency_mode: ConcurrencyMode = "sequential" - queue_rank: int | None = None - execution_group: str | None = None - scheduled_for: datetime | None = None - created_at: datetime - updated_at: datetime - - -class WorkstreamWithTaskCounts(WorkstreamRead): - tasks_total: int = 0 - tasks_wait: int = 0 - tasks_todo: int = 0 - tasks_progress: int = 0 - tasks_done: int = 0 - tasks_cancel: int = 0 - - -class WorkstreamWithDeps(WorkstreamWithTaskCounts): - """WorkstreamWithTaskCounts enriched with dependency graph edges.""" - depends_on: list[WorkstreamDepStub] = [] - blocks: list[WorkstreamDepStub] = [] - blocked_reasons: list[dict] = [] +__all__ = [ + "WorkstreamStatus", + "WorkstreamStatusMixin", + "WorkstreamCreate", + "WorkstreamUpdate", + "WorkstreamRead", + "WorkstreamWithTaskCounts", + "WorkstreamWithDeps", + "WorkplanStatus", + "WorkplanStatusMixin", + "WorkplanCreate", + "WorkplanUpdate", + "WorkplanRead", + "WorkplanWithTaskCounts", + "WorkplanWithDeps", + "ExecutionState", + "LaunchMode", + "ConcurrencyMode", +] \ No newline at end of file diff --git a/api/schemas/workstream_dependency.py b/api/schemas/workstream_dependency.py index ec17ed7..d238d6b 100644 --- a/api/schemas/workstream_dependency.py +++ b/api/schemas/workstream_dependency.py @@ -1,36 +1,19 @@ -import uuid -from datetime import datetime +"""Legacy aliases — prefer ``api.schemas.workplan_dependency``.""" +from api.schemas.workplan_dependency import ( + WorkplanDepStub, + WorkplanDependencyCreate, + WorkplanDependencyRead, +) -from pydantic import BaseModel, ConfigDict +WorkstreamDependencyCreate = WorkplanDependencyCreate +WorkstreamDependencyRead = WorkplanDependencyRead +WorkstreamDepStub = WorkplanDepStub - -class WorkstreamDependencyCreate(BaseModel): - to_workstream_id: uuid.UUID | None = None - to_task_id: uuid.UUID | None = None - relationship_type: str = "blocks" - description: str | None = None - - -class WorkstreamDependencyRead(BaseModel): - model_config = ConfigDict(from_attributes=True) - id: uuid.UUID - from_workstream_id: uuid.UUID - to_workstream_id: uuid.UUID | None = None - to_task_id: uuid.UUID | None = None - relationship_type: str - description: str | None = None - created_at: datetime - updated_at: datetime - - -class WorkstreamDepStub(BaseModel): - """Minimal projection of the other end of a dependency edge.""" - dep_id: uuid.UUID - target_type: str = "workstream" - relationship_type: str = "blocks" - workstream_id: uuid.UUID | None = None - workstream_slug: str | None = None - workstream_title: str | None = None - task_id: uuid.UUID | None = None - task_title: str | None = None - description: str | None = None +__all__ = [ + "WorkstreamDependencyCreate", + "WorkstreamDependencyRead", + "WorkstreamDepStub", + "WorkplanDependencyCreate", + "WorkplanDependencyRead", + "WorkplanDepStub", +] \ No newline at end of file diff --git a/api/services/execution_queue.py b/api/services/execution_queue.py index 07f86ff..01a5263 100644 --- a/api/services/execution_queue.py +++ b/api/services/execution_queue.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from api.workplan_status import normalize_workstream_status +from api.workplan_status import normalize_workplan_status EXECUTION_STATES = { @@ -57,7 +57,7 @@ PRIORITY_RANK = { "low": 3, } -CLOSED_WORKSTREAM_STATUSES = {"finished", "archived"} +CLOSED_WORKPLAN_STATUSES = {"finished", "archived"} def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) -> str: @@ -71,19 +71,24 @@ def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) return "queued" -def workstream_blockers( - workstream_id: Any, +def workplan_blockers( + workplan_id: Any, dependency_targets: dict[Any, list[Any]], - workstream_status: dict[Any, str], + workplan_status: dict[Any, str], + workstream_id: Any = None, ) -> list[Any]: + scope_id = workplan_id if workplan_id is not None else workstream_id blockers = [] - for target_id in dependency_targets.get(workstream_id, []): - target_status = normalize_workstream_status(workstream_status.get(target_id)) - if target_status not in CLOSED_WORKSTREAM_STATUSES: + for target_id in dependency_targets.get(scope_id, []): + target_status = normalize_workplan_status(workplan_status.get(target_id)) + if target_status not in CLOSED_WORKPLAN_STATUSES: blockers.append(target_id) return blockers +workstream_blockers = workplan_blockers + + def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]: priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower() execution_state = str(getattr(workstream, "execution_state", "") or "manual").strip().lower() diff --git a/api/services/lifecycle.py b/api/services/lifecycle.py index 5ee9383..ea77025 100644 --- a/api/services/lifecycle.py +++ b/api/services/lifecycle.py @@ -4,14 +4,16 @@ from dataclasses import dataclass from typing import Any from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value -from api.workplan_status import normalize_workstream_status - +from api.workplan_status import normalize_workplan_status TASK_STARTED_STATUS = "progress" TASK_NOT_STARTED_STATUS = "todo" TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"} +# Legacy alias +normalize_workstream_status = normalize_workplan_status + @dataclass(frozen=True) class LifecycleTransitionResult: @@ -26,13 +28,15 @@ def should_activate_parent_for_task_start( *, previous_task_status: Any, new_task_status: Any, - parent_workstream_status: Any, + parent_workplan_status: Any = None, + parent_workstream_status: Any = None, ) -> bool: """Return whether a task start should move its parent to active.""" + parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status return ( status_value(previous_task_status) == TASK_NOT_STARTED_STATUS and status_value(new_task_status) == TASK_STARTED_STATUS - and normalize_workstream_status(parent_workstream_status) + and normalize_workplan_status(parent_status) in PARENT_ACTIVATION_STATUSES ) @@ -44,12 +48,14 @@ def has_active_task_status(task_statuses: list[Any] | tuple[Any, ...]) -> bool: def should_activate_parent_for_active_tasks( *, - parent_workstream_status: Any, + parent_workplan_status: Any = None, + parent_workstream_status: Any = None, task_statuses: list[Any] | tuple[Any, ...], ) -> bool: - """Return whether existing task state implies an active parent workstream.""" + """Return whether existing task state implies an active parent workplan.""" + parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status return ( - normalize_workstream_status(parent_workstream_status) + normalize_workplan_status(parent_status) in PARENT_ACTIVATION_STATUSES and has_active_task_status(task_statuses) ) @@ -59,46 +65,54 @@ def activate_parent_for_task_start( *, previous_task_status: Any, new_task_status: Any, - parent_workstream: Any, + parent_workplan: Any = None, + parent_workstream: Any = None, ) -> bool: - """Activate a planning-state parent workstream when real task work starts.""" - if parent_workstream is None: + """Activate a planning-state parent workplan when real task work starts.""" + parent = parent_workplan if parent_workplan is not None else parent_workstream + if parent is None: return False if not should_activate_parent_for_task_start( previous_task_status=previous_task_status, new_task_status=new_task_status, - parent_workstream_status=getattr(parent_workstream, "status", None), + parent_workplan_status=getattr(parent, "status", None), + parent_workstream_status=getattr(parent, "status", None), ): return False - parent_workstream.status = "active" + parent.status = "active" return True -def transition_workstream_status( - workstream: Any, +def transition_workplan_status( + workplan: Any, target_status: Any, ) -> LifecycleTransitionResult: - """Apply a canonical workstream status transition.""" - previous_status = normalize_workstream_status(getattr(workstream, "status", None)) - normalised_target = normalize_workstream_status(target_status) - workstream.status = normalised_target + """Apply a canonical workplan status transition.""" + previous_status = normalize_workplan_status(getattr(workplan, "status", None)) + normalised_target = normalize_workplan_status(target_status) + workplan.status = normalised_target return LifecycleTransitionResult( - entity_type="workstream", + entity_type="workplan", previous_status=previous_status, target_status=normalised_target, changed=previous_status != normalised_target, ) +transition_workstream_status = transition_workplan_status + + def transition_task_status( task: Any, target_status: Any, *, + parent_workplan: Any = None, parent_workstream: Any = None, previous_task_status: Any = None, status_coercer: Any = None, ) -> LifecycleTransitionResult: """Apply a task status transition and activate the parent when work starts.""" + parent = parent_workplan if parent_workplan is not None else parent_workstream previous_status = status_value( getattr(task, "status", None) if previous_task_status is None @@ -109,7 +123,8 @@ def transition_task_status( parent_activated = activate_parent_for_task_start( previous_task_status=previous_status, new_task_status=normalised_target, - parent_workstream=parent_workstream, + parent_workplan=parent, + parent_workstream=parent, ) return LifecycleTransitionResult( entity_type="task", @@ -117,4 +132,4 @@ def transition_task_status( target_status=normalised_target, changed=previous_status != normalised_target, parent_activated=parent_activated, - ) + ) \ No newline at end of file diff --git a/api/services/recently_on_scope.py b/api/services/recently_on_scope.py index 5e28707..359ec42 100644 --- a/api/services/recently_on_scope.py +++ b/api/services/recently_on_scope.py @@ -20,7 +20,7 @@ from api.models.managed_repo import ManagedRepo from api.models.progress_event import ProgressEvent from api.models.task import Task, TaskStatus from api.models.topic import Topic -from api.models.workstream import Workstream +from api.models.workplan import Workplan from api.schemas.recently_on_scope import ( RecentlyOnScopeFailedDomain, RecentlyOnScopeHourlyRun, @@ -344,11 +344,11 @@ async def _list_topics(domain_id: uuid.UUID, session: AsyncSession) -> list[Topi return list(result.scalars().all()) -async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workstream]: +async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workplan]: result = await session.execute( - select(Workstream) - .where(_in(Workstream.topic_id, topic_ids)) - .order_by(Workstream.updated_at.desc(), Workstream.created_at.desc()) + select(Workplan) + .where(_in(Workplan.topic_id, topic_ids)) + .order_by(Workplan.updated_at.desc(), Workplan.created_at.desc()) ) return list(result.scalars().all()) @@ -356,7 +356,7 @@ async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) - async def _list_tasks(workstream_ids: list[uuid.UUID], session: AsyncSession) -> list[Task]: result = await session.execute( select(Task) - .where(_in(Task.workstream_id, workstream_ids)) + .where(_in(Task.workplan_id, workstream_ids)) .order_by(Task.updated_at.desc(), Task.created_at.desc()) ) return list(result.scalars().all()) @@ -370,7 +370,7 @@ async def _list_recent_decisions( ) -> list[Decision]: result = await session.execute( select(Decision) - .where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workstream_id, workstream_ids))) + .where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workplan_id, workstream_ids))) .where( or_( _between(Decision.created_at, window), @@ -397,7 +397,7 @@ async def _list_recent_progress( .where( or_( _in(ProgressEvent.topic_id, topic_ids), - _in(ProgressEvent.workstream_id, workstream_ids), + _in(ProgressEvent.workplan_id, workstream_ids), _in(ProgressEvent.task_id, task_ids), _in(ProgressEvent.decision_id, decision_ids), ) @@ -550,7 +550,8 @@ def _progress_data(event: ProgressEvent) -> dict[str, Any]: "event_type": event.event_type, "summary": event.summary, "author": event.author, - "workstream_id": str(event.workstream_id) if event.workstream_id else None, + "workplan_id": str(event.workplan_id) if event.workplan_id else None, + "workstream_id": str(event.workplan_id) if event.workplan_id else None, "task_id": str(event.task_id) if event.task_id else None, "decision_id": str(event.decision_id) if event.decision_id else None, } @@ -569,7 +570,7 @@ def _decision_data(decision: Decision) -> dict[str, Any]: } -def _workstream_data(workstream: Workstream) -> dict[str, Any]: +def _workstream_data(workstream: Workplan) -> dict[str, Any]: return { "id": str(workstream.id), "slug": workstream.slug, @@ -584,7 +585,7 @@ def _workstream_data(workstream: Workstream) -> dict[str, Any]: def _task_data(task: Task) -> dict[str, Any]: return { "id": str(task.id), - "workstream_id": str(task.workstream_id), + "workstream_id": str(task.workplan_id), "title": task.title, "status": _enum_value(task.status), "priority": _enum_value(task.priority), diff --git a/api/services/reconciliation.py b/api/services/reconciliation.py index d05538f..af953e2 100644 --- a/api/services/reconciliation.py +++ b/api/services/reconciliation.py @@ -6,7 +6,7 @@ from typing import Any from api.services.lifecycle import status_value from api.task_status import CANONICAL_TASK_STATUSES -from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status +from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status class ReconciliationClass(str, Enum): @@ -22,11 +22,11 @@ class StateChangeClassification: follow_up: str -WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"} +WRITE_THROUGH_WORKPLAN_STATUSES = {"proposed", "ready", "active", "backlog"} TASK_STATUSES = set(CANONICAL_TASK_STATUSES) -def classify_workstream_status_change( +def classify_workplan_status_change( *, current_status: Any, target_status: Any, @@ -35,8 +35,8 @@ def classify_workstream_status_change( tasks_terminal: bool | None = None, ) -> StateChangeClassification: """Classify a UI-originated workstream status transition.""" - current = normalize_workstream_status(current_status) - target = normalize_workstream_status(target_status) + current = normalize_workplan_status(current_status) + target = normalize_workplan_status(target_status) if not file_backed: return StateChangeClassification( @@ -56,7 +56,7 @@ def classify_workstream_status_change( "status is unchanged", "no file update required", ) - if target in WRITE_THROUGH_WORKSTREAM_STATUSES and current not in CLOSED_WORKSTREAM_STATUSES: + if target in WRITE_THROUGH_WORKPLAN_STATUSES and current not in CLOSED_WORKPLAN_STATUSES: return StateChangeClassification( ReconciliationClass.WRITE_THROUGH, "open lifecycle transition can be represented in workplan frontmatter", @@ -93,6 +93,9 @@ def classify_workstream_status_change( ) +classify_workstream_status_change = classify_workplan_status_change + + def classify_task_status_change( *, current_status: Any, diff --git a/api/services/workplan_files.py b/api/services/workplan_files.py index 3abd034..fef57fe 100644 --- a/api/services/workplan_files.py +++ b/api/services/workplan_files.py @@ -41,9 +41,9 @@ def resolve_repo_path(repo: ManagedRepo | None) -> Path | None: return None -def find_workplan_for_workstream( +def find_workplan_for_workplan( repo: ManagedRepo | None, - workstream_id: uuid.UUID, + workplan_id: uuid.UUID, ) -> WorkplanFileRef | None: repo_path = resolve_repo_path(repo) if repo_path is None: @@ -57,11 +57,15 @@ def find_workplan_for_workstream( continue for path in sorted(directory.glob("*.md")): meta = _frontmatter(path) - if str(meta.get("state_hub_workstream_id", "")).strip().strip('"') == str(workstream_id): + file_id = meta.get("state_hub_workplan_id") or meta.get("state_hub_workstream_id") + if str(file_id or "").strip().strip('"') == str(workplan_id): return WorkplanFileRef(repo_path=repo_path, path=path, archived=archived) return None +find_workplan_for_workstream = find_workplan_for_workplan + + def task_block_linked(path: Path, task_id: uuid.UUID) -> bool: return _task_block_for_task(path, task_id) is not None diff --git a/api/workplan_status.py b/api/workplan_status.py index 4180225..a9e9d69 100644 --- a/api/workplan_status.py +++ b/api/workplan_status.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = ( +CANONICAL_WORKPLAN_STATUSES: tuple[str, ...] = ( "proposed", "ready", "active", @@ -17,22 +17,31 @@ CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = ( "archived", ) -LEGACY_WORKSTREAM_STATUS_ALIASES: dict[str, str] = { +LEGACY_WORKPLAN_STATUS_ALIASES: dict[str, str] = { "todo": "ready", "done": "finished", "completed": "finished", "accepted": "finished", } -SUPPORTED_WORKSTREAM_STATUSES: tuple[str, ...] = ( - *CANONICAL_WORKSTREAM_STATUSES, - *LEGACY_WORKSTREAM_STATUS_ALIASES.keys(), +SUPPORTED_WORKPLAN_STATUSES: tuple[str, ...] = ( + *CANONICAL_WORKPLAN_STATUSES, + *LEGACY_WORKPLAN_STATUS_ALIASES.keys(), ) -OPEN_WORKSTREAM_STATUSES: tuple[str, ...] = ("ready", "active", "blocked") -CURRENT_WORKSTREAM_STATUSES: tuple[str, ...] = ("active", "blocked") -CLOSED_WORKSTREAM_STATUSES: tuple[str, ...] = ("finished", "archived") -PLANNING_WORKSTREAM_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog") +OPEN_WORKPLAN_STATUSES: tuple[str, ...] = ("ready", "active", "blocked") +CURRENT_WORKPLAN_STATUSES: tuple[str, ...] = ("active", "blocked") +CLOSED_WORKPLAN_STATUSES: tuple[str, ...] = ("finished", "archived") +PLANNING_WORKPLAN_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog") + +# Legacy aliases (workstream terminology) +CANONICAL_WORKSTREAM_STATUSES = CANONICAL_WORKPLAN_STATUSES +LEGACY_WORKSTREAM_STATUS_ALIASES = LEGACY_WORKPLAN_STATUS_ALIASES +SUPPORTED_WORKSTREAM_STATUSES = SUPPORTED_WORKPLAN_STATUSES +OPEN_WORKSTREAM_STATUSES = OPEN_WORKPLAN_STATUSES +CURRENT_WORKSTREAM_STATUSES = CURRENT_WORKPLAN_STATUSES +CLOSED_WORKSTREAM_STATUSES = CLOSED_WORKPLAN_STATUSES +PLANNING_WORKSTREAM_STATUSES = PLANNING_WORKPLAN_STATUSES @dataclass(frozen=True) @@ -42,26 +51,38 @@ class ReadyReviewStatus: changed_paths: tuple[str, ...] = () -def normalize_workstream_status(status: Any, *, has_started: bool | None = None) -> str: +def normalize_workplan_status(status: Any, *, has_started: bool | None = None) -> str: """Return the canonical lifecycle status for a stored or legacy value.""" value = _status_value(status) if value == "todo" and has_started: return "active" - return LEGACY_WORKSTREAM_STATUS_ALIASES.get(value, value) + return LEGACY_WORKPLAN_STATUS_ALIASES.get(value, value) -def is_canonical_workstream_status(status: Any) -> bool: - return _status_value(status) in CANONICAL_WORKSTREAM_STATUSES +normalize_workstream_status = normalize_workplan_status -def is_supported_workstream_status(status: Any) -> bool: - return _status_value(status) in SUPPORTED_WORKSTREAM_STATUSES +def is_canonical_workplan_status(status: Any) -> bool: + return _status_value(status) in CANONICAL_WORKPLAN_STATUSES -def workstream_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool: +is_canonical_workstream_status = is_canonical_workplan_status + + +def is_supported_workplan_status(status: Any) -> bool: + return _status_value(status) in SUPPORTED_WORKPLAN_STATUSES + + +is_supported_workstream_status = is_supported_workplan_status + + +def workplan_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool: return any(_status_value(status) not in {"", "todo"} for status in task_statuses) +workstream_has_started = workplan_has_started + + def ready_review_status( repo_dir: str | Path, reviewed_against_commit: Any, diff --git a/dashboard/src/docs/domains.md b/dashboard/src/docs/domains.md index 82b8010..72389f4 100644 --- a/dashboard/src/docs/domains.md +++ b/dashboard/src/docs/domains.md @@ -4,27 +4,36 @@ title: Domains — Reference # Domains — Reference -The Domains page shows all registered project domains and the repositories -associated with each one. Domains are the top-level organisational unit of the -Custodian ecosystem. +The Domains page shows the **14 fixed market domains** from the Repo +Classification Standard. These replaced the old ad-hoc coordination domains +(custodian, railiance, markitect, …) in STATE-WP-0065. --- ## What is a domain? -A domain corresponds to one of the six tracked project areas: +A domain is an intended **market / user segment** — not a project org unit. +Each registered repo has exactly one primary domain (from its +`.repo-classification.yaml`), stored on `managed_repos.domain_id`. -| Slug | Project | +| Slug | Segment | |------|---------| -| `custodian` | The Custodian agent system itself | -| `railiance` | DevOps & infrastructure reliability | -| `markitect` | Knowledge artifact management | -| `coulomb_social` | Co-creation marketplace | -| `personhood` | Rights & obligations framework | -| `foerster_capabilities` | Agency capability taxonomy | +| `infotech` | Developers, platforms, internal tooling users | +| `financials` | Finance, trading, payments | +| `communication` | Messaging, social, collaboration | +| `consumer` | General consumers | +| `health` | Healthcare, wellness | +| `industrials` | Manufacturing, logistics | +| `energy` | Energy sector | +| `utilities` | Utilities infrastructure | +| `materials` | Materials / commodities | +| `realestate` | Property, housing | +| `crypto` | Crypto / web3 | +| `agents` | AI-native agent users | +| `space` | Space industry | +| `government` | Civic, public sector | -Each domain has a slug (URL-friendly identifier), a human-readable name, an -optional description, and a status. +Canon: `the-custodian/canon/standards/repo-classification-standard_v1.0.md` --- @@ -32,63 +41,21 @@ optional description, and a status. | Status | Meaning | |--------|---------| -| **active** | Live domain — topics, workstreams, and tasks are being tracked | -| **archived** | Soft-deleted; no active work. Fails to archive if active topics exist | +| **active** | Live domain — repos and workplans may reference it | +| **archived** | Retired; no new registrations | --- -## KPI row +## Relationship to repos and workplans -Four counters at the top of the page: - -| Counter | Meaning | -|---------|---------| -| Total domains | All registered domains regardless of status | -| Active | Domains with status `active` | -| Total repos | Sum of all registered repositories across all domains | -| Newest domain | Name of the most recently created domain | +- **Repos** are the primary anchor — classification file is source of truth. +- **Workplans** require `repo_id`; market domain is derived from the repo. +- **Topics** are optional legacy tags; workplan frontmatter `domain:` may still + use old coordination slugs — the consistency checker maps these to market domains. --- -## Domain cards +## Related -One card per domain showing: - -- **Slug** — monospace identifier -- **Status badge** — green `active` or grey `archived` -- **Name** — display name -- **Description** — first 160 characters -- **Repos** — list of registered repositories for this domain, each showing name, local path, and remote URL - ---- - -## RecentlyOnScope - -The `Domains / RecentlyOnScope` page generates deterministic Markdown digests -for a selected domain. The range parameter defaults to `1h` and accepts compact -durations such as `15m`, `6h`, or `1d`. - -Generated reports are written under the configured State Hub report directory, -defaulting to `reports/recently-on-scope//`. The dashboard lists -those Markdown files and previews the raw report content. - ---- - -## Managing domains - -Via MCP: - -``` -create_domain(slug="my_project", name="My Project", description="…") -rename_domain(slug="old_slug", new_slug="new_slug", new_name="New Name") -archive_domain(slug="my_project") # fails if active topics exist -``` - -Via Makefile: - -```bash -make add-domain SLUG=my_project NAME="My Project" -make rename-domain OLD_SLUG=my_project NEW_SLUG=myproject NEW_NAME="My Project" -``` - -*Domains are never hard-deleted — only archived.* +- **[Repos](/docs/repos)** — portfolio view with category / capability filters +- **[Repo Integration](/docs/repo-integration)** — onboarding with classification file \ No newline at end of file diff --git a/dashboard/src/docs/repos.md b/dashboard/src/docs/repos.md index 06cd6b9..50d5c0d 100644 --- a/dashboard/src/docs/repos.md +++ b/dashboard/src/docs/repos.md @@ -5,18 +5,25 @@ title: Repos — Reference # Repos — Reference The Repos page shows every repository registered in the Custodian ecosystem, -their SBOM ingestion status, and a domain-grouped coverage map. +their **classification** (category, market domain, capabilities, business stake), +SBOM ingestion status, and a domain-grouped coverage map. --- ## What is a managed repo? -A managed repo is a git repository that has been registered with the state hub -via `custodian register-project` or `register_repo()`. Registration records the -repo's slug, domain, local path, and optional remote URL. Once registered, the -repo receives a `CLAUDE.custodian.md` integration suggestion, an onboarding -workstream with 4 tasks for the repo agent, and is eligible for SBOM ingestion -and the ADR-001 workplan validator. +A managed repo is a git repository registered with State Hub. Registration is +**classification-driven**: + +1. Commit `.repo-classification.yaml` per the Repo Classification Standard. +2. Run `make register-from-classification REPO=` (or use the MCP tool + `register_repo_from_classification`). + +The file is the source of truth; the hub stores a validated copy on +`managed_repos` (category, domain, capability_tags, business_stake, provenance). + +Legacy `custodian register-project` still works for agent onboarding but should +be followed by classification registration. For the full onboarding journey see **[Repo Integration](/docs/repo-integration)**. @@ -27,69 +34,56 @@ For the full onboarding journey see **[Repo Integration](/docs/repo-integration) | Card | Meaning | |------|---------| | **Registered Repos** | Active repos only (status = active) | -| **Domains** | Count of distinct domain slugs across registered repos | +| **Market Domains** | Distinct primary domains across registered repos | +| **Categories** | Distinct work categories (experimental, tooling, product, …) | | **SBOM Ingested** | Repos with at least one SBOM snapshot | | **SBOM Gaps** | Repos with no ingested SBOM — red border when > 0 | --- -## Coverage Map +## Portfolio by Category -Groups repos by domain. Each domain block shows: - -- **Domain name** with SBOM, EP, and TD chip indicators -- **SBOM chip** — green ✓ if all repos in the domain are ingested, amber ⚠ if any gap exists -- **EPs chip** — count of open/in-progress extension points for this domain -- **TDs chip** — count of open/in-progress technical debt items for this domain -- **Repo table** — one row per repo with SBOM status, package count, and local path - -Rows with no SBOM are highlighted in amber. +Groups repos by `category` (experimental, research, project, tooling, product, +business). Each block shows domain, capabilities, business stake, and who +classified the repo (`human` vs `migration`). --- -## Filters +## Coverage Map + +Groups repos by **market domain**. Each domain block shows SBOM, EP, and TD +chips plus per-repo classification columns. + +--- + +## Filters (All Repos Table) | Filter | Effect | |--------|--------| -| **Domain** | Show repos for a single domain only | -| **Gaps only** | Toggle to show only repos without an ingested SBOM | +| **Market domain** | Primary domain slug | +| **Category** | Repo work category | +| **Capability** | Repos tagged with a capability | +| **Business stake** | Repos affecting a business responsibility area | +| **DoI tier** | Definition of Integrated tier | +| **Gaps only** | Repos without ingested SBOM | + +--- + +## Consistency (C-24) + +The ADR-001 consistency checker warns when a registered repo lacks a valid +`.repo-classification.yaml` on disk. Migration-derived rows (`classified_by: +migration`) get an explanatory note until a human-reviewed file is committed. --- ## Onboarding a new repo -See **[Repo Integration](/docs/repo-integration)** for the full journey. - -Quick reference: +Use the **Add Repo** form or: ```bash -# From the repo root — registers, writes CLAUDE.custodian.md, creates onboarding tasks -custodian register-project --domain -``` - -## Ingesting a repo's SBOM - -```bash -# Auto-detects lockfile at repo root -cd ~/state-hub -make ingest-sbom REPO= REPO_PATH=/absolute/path - -# Multi-ecosystem repo — scan all lockfiles recursively -make ingest-sbom REPO= 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.* +# 1. Author classification file in the repo +# 2. Register / reclassify +make register-from-classification PATH=/path/to/repo +make fix-consistency REPO= +``` \ No newline at end of file diff --git a/dashboard/src/repos.md b/dashboard/src/repos.md index 1d82e36..2f06d98 100644 --- a/dashboard/src/repos.md +++ b/dashboard/src/repos.md @@ -102,14 +102,28 @@ const repoRows = repos const integrating = !!integratingBySlug[r.slug]; const doiEntry = doiBySlug[r.slug] ?? null; const doiTier = doiEntry?.tier ?? "none"; + const category = r.category ?? "—"; + const capList = r.capability_tags ?? []; + const stakeList = r.business_stake ?? []; + const capTags = capList.length + ? capList.slice(0, 3).join(", ") + (capList.length > 3 ? "…" : "") + : "—"; + const classifiedBy = r.classified_by ?? "—"; return { _id: r.id, _domSlug: domSlug, + _category: category, + _capList: capList, + _stakeList: stakeList, _hasSbom: hasSbom, _integrating: integrating, _doiTier: doiTier, repo: r.slug, domain: domName, + category: category, + capTags: capTags, + businessStake: stakeList.length ? stakeList.slice(0, 3).join(", ") : "—", + classifiedBy: classifiedBy, status: integrating ? "⚙ integrating" : "ready", path: r.local_path ?? "—", sbom: hasSbom ? `✓ ${lastScan}` : "⚠ not ingested", @@ -153,9 +167,13 @@ display(html`

${repoRows.length}

-

Domains

+

Market Domains

${new Set(repoRows.map(r => r._domSlug)).size}

+
+

Categories

+

${new Set(repoRows.map(r => r._category).filter(c => c !== "—")).size}

+

Integrating

${integratingCount}

@@ -240,6 +258,8 @@ if (domainBlocks.length === 0) { + + @@ -249,6 +269,8 @@ if (domainBlocks.length === 0) { ${rows.map(r => html` + +
RepoCategoryCapabilities DoI Tier Status SBOM
${r.repo}${r.category}${r.capTags} ${_doiBadge(r._doiTier)} ${r._integrating ? html`⚙ integrating` @@ -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`

Portfolio by Category

+
+ ${categoryBlocks.map(([cat, rows]) => html` +
+
+ ${cat} + + ${rows.length} repo(s) + ${new Set(rows.map(r => r._domSlug)).size} domain(s) + +
+ + + + + + ${rows.map(r => html` + + + + + + `)} + +
RepoDomainCapabilitiesBusiness stakeClassified
${r.repo}${r.domain}${r.capTags}${r.businessStake}${r.classifiedBy}
+
+ `)} +
`); +} +``` + ## All Repos Table ```js -const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Domain", 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`
${domainFilter}${doiFilter}${gapFilter}
`); +const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Market domain", value: "all"}); +const categoryFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._category).filter(c => c !== "—")).values()], {label: "Category", value: "all"}); +const capabilityFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._capList)).values()].sort(), {label: "Capability", value: "all"}); +const stakeFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._stakeList)).values()].sort(), {label: "Business stake", value: "all"}); +const doiFilter = Inputs.select(["all", "none", "core", "standard", "full"], {label: "DoI tier", value: "all"}); +const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false}); +display(html`
${domainFilter}${categoryFilter}${capabilityFilter}${stakeFilter}${doiFilter}${gapFilter}
`); ``` ```js const filteredRows = repoRows.filter(r => (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) ); display(Inputs.table(filteredRows.map(r => ({ Repo: r.repo, Domain: r.domain, + Category: r.category, + Capabilities: r.capTags, + "Business stake": r.businessStake, + Classified: r.classifiedBy, "DoI Tier": DOI_TIER_LABEL[r._doiTier] ?? r._doiTier, Status: r.status, SBOM: r.sbom, diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 4ecda3c..0629790 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -20,6 +20,29 @@ Do not use them as a substitute for formal work definition inside the domain rep --- +## Workplan terminology (STATE-WP-0065) + +**Preferred terms:** workplan, `workplan_id`, `/workplans/…` + +**Legacy compatibility:** `workstream`, `workstream_id`, `/workstreams/…`, and +`create_workstream` / `update_workstream` MCP tools remain available as aliases. +They call the same implementation as the workplan-named tools and endpoints. + +| Preferred (workplan) | Legacy alias (workstream) | +|---|---| +| `create_workplan(repo_id, …)` | `create_workstream(repo_id, …)` | +| `update_workplan` / `update_workplan_status` | `update_workstream` / `update_workstream_status` | +| `list_workplans` | `list_workstreams` | +| `create_workplan_dependency` | `create_dependency` | +| `POST /workplans/` | `POST /workstreams/` (deprecated headers) | +| `workplan_id` query/body field | `workstream_id` (accepted alias) | + +Repo classification filters: `list_repos_by_classification(category?, domain?, +capability_tag?, business_stake?)` and extended `list_domain_repos(...)` query +params use `GET /repos/` classification spine fields. + +--- + ## MCP/REST Parity and Failure Handling The MCP server is a thin stateless HTTP client over the FastAPI service. On @@ -28,6 +51,10 @@ endpoint they wrap: | MCP tool | REST endpoint | |---|---| +| `create_workplan(...)` | `POST /workplans/` | +| `list_workplans(...)` | `GET /workplans/` | +| `update_workplan_status(...)` | `PATCH /workplans/{workplan_id}` | +| `list_repos_by_classification(...)` | `GET /repos/?category=…` | | `create_workstream(...)` | `POST /workstreams/` | | `create_task(...)` | `POST /tasks/` | | `update_task_status(...)` | `PATCH /tasks/{task_id}` | diff --git a/mcp_server/server.py b/mcp_server/server.py index 95d7d88..96696a0 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -358,18 +358,29 @@ def create_topic(slug: str, title: str, domain: str, description: str | None = N @mcp.tool() -def list_tasks(workstream_id: str, status: str | None = None) -> str: - """List all tasks in a workstream, optionally filtered by status. +def list_tasks( + workplan_id: str | None = None, + workstream_id: str | None = None, + status: str | None = None, +) -> str: + """List all tasks in a workplan, optionally filtered by status. Args: - workstream_id: UUID of the workstream (required). + workplan_id: UUID of the workplan (preferred). + workstream_id: legacy alias for workplan_id. status: Optional filter — wait | todo | progress | done | cancel. Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every matching task. Use this to look up task UUIDs before calling update_task_status, or to check which tasks from a workplan file are already synced to the DB. """ - return json.dumps(_get("/tasks", {"workstream_id": workstream_id, "status": status}), indent=2) + parent_id = workplan_id or workstream_id + if not parent_id: + return _json_result(_mcp_error("list_tasks", "workplan_id is required")) + return json.dumps( + _get("/tasks", {"workplan_id": parent_id, "workstream_id": parent_id, "status": status}), + indent=2, + ) @mcp.tool() @@ -455,37 +466,30 @@ def advance_workstation(entity_type: str, entity_id: str, target_workstation: st # --------------------------------------------------------------------------- -# Mutate tools +# Workplan helpers (preferred) + legacy workstream aliases # --------------------------------------------------------------------------- -@mcp.tool() -def create_workstream( - topic_id: str, +def _workplan_id_from_response(payload: dict[str, Any]) -> str | None: + return payload.get("workplan_id") or payload.get("workstream_id") or payload.get("id") + + +def _create_workplan_impl( + *, + repo_id: str, title: str, + topic_id: str | None = None, slug: str | None = None, description: str | None = None, owner: str | None = None, due_date: str | None = None, - repo_id: str | None = None, planning_priority: str | None = None, planning_order: int | None = None, + tool_name: str = "create_workplan", ) -> str: - """Create a new workstream under a topic and emit a progress_event. - - Args: - topic_id: UUID of the parent topic - title: workstream title - slug: URL-friendly identifier (auto-generated from title if omitted) - description: optional longer description - owner: optional owner name - due_date: optional ISO date string (YYYY-MM-DD) - repo_id: UUID of the owning repository (GEMS primary; strongly recommended per ADR-001) - planning_priority: optional planning priority (critical/high/medium/low or repo-local value) - planning_order: optional numeric ordering hint inside a repo/domain - """ if not slug: slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") - ws = _post("/workstreams", { + wp = _post("/workplans", { + "repo_id": repo_id, "topic_id": topic_id, "title": title, "slug": slug, @@ -493,30 +497,147 @@ def create_workstream( "owner": owner, "due_date": due_date, "status": "active", - "repo_id": repo_id, "planning_priority": planning_priority, "planning_order": planning_order, }) - if error := _response_error("create_workstream", ws, ("id",)): + if error := _response_error(tool_name, wp, ("id",)): return _json_result(error) - progress_error = _emit_progress_event("create_workstream", ws, { + progress_error = _emit_progress_event(tool_name, wp, { "topic_id": topic_id, - "workstream_id": ws["id"], - "event_type": "workstream_created", - "summary": f"Workstream created: {title}", + "workplan_id": wp["id"], + "workstream_id": wp["id"], + "event_type": "workplan_created", + "summary": f"Workplan created: {title}", "author": "custodian", - "detail": {"owner": owner, "slug": slug}, + "detail": {"owner": owner, "slug": slug, "repo_id": repo_id}, }) if progress_error: return _json_result(progress_error) - return _json_result(ws) + return _json_result(wp) + + +def _update_workplan_status_impl(workplan_id: str, status: str, *, tool_name: str) -> str: + wp = _patch(f"/workplans/{workplan_id}", {"status": status}) + if error := _response_error(tool_name, wp, ("id", "title")): + return _json_result(error) + + progress_error = _emit_progress_event(tool_name, wp, { + "workplan_id": workplan_id, + "workstream_id": workplan_id, + "topic_id": wp.get("topic_id"), + "event_type": "workplan_status_changed", + "summary": f"Workplan status → {status}: {wp['title']}", + "author": "custodian", + }) + if progress_error: + return _json_result(progress_error) + return _json_result(wp) + + +def _update_workplan_impl( + workplan_id: str, + *, + title: str | None = None, + description: str | None = None, + owner: str | None = None, + due_date: str | None = None, + repo_goal_id: str | None = None, + status: str | None = None, +) -> str: + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if description is not None: + payload["description"] = description + if owner is not None: + payload["owner"] = owner + if due_date is not None: + payload["due_date"] = due_date + if status is not None: + payload["status"] = status + if repo_goal_id is not None: + payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None + return _json_result(_patch(f"/workplans/{workplan_id}", payload)) + + +# --------------------------------------------------------------------------- +# Mutate tools +# --------------------------------------------------------------------------- + +@mcp.tool() +def create_workplan( + repo_id: str, + title: str, + topic_id: str | None = None, + slug: str | None = None, + description: str | None = None, + owner: str | None = None, + due_date: str | None = None, + planning_priority: str | None = None, + planning_order: int | None = None, +) -> str: + """Create a new repo-anchored workplan and emit a progress_event. + + Args: + repo_id: UUID of the owning repository (required) + title: workplan title + topic_id: optional topic UUID for cross-repo tagging + slug: URL-friendly identifier (auto-generated from title if omitted) + description: optional longer description + owner: optional owner name + due_date: optional ISO date string (YYYY-MM-DD) + planning_priority: optional planning priority (critical/high/medium/low or repo-local value) + planning_order: optional numeric ordering hint inside a repo + """ + return _create_workplan_impl( + repo_id=repo_id, + title=title, + topic_id=topic_id, + slug=slug, + description=description, + owner=owner, + due_date=due_date, + planning_priority=planning_priority, + planning_order=planning_order, + tool_name="create_workplan", + ) + + +@mcp.tool() +def create_workstream( + title: str, + repo_id: str | None = None, + topic_id: str | None = None, + slug: str | None = None, + description: str | None = None, + owner: str | None = None, + due_date: str | None = None, + planning_priority: str | None = None, + planning_order: int | None = None, +) -> str: + """Legacy alias for create_workplan — prefer create_workplan(repo_id=...).""" + if not repo_id: + return _json_result(_mcp_error("create_workstream", "repo_id is required")) + return _create_workplan_impl( + repo_id=repo_id, + title=title, + topic_id=topic_id, + slug=slug, + description=description, + owner=owner, + due_date=due_date, + planning_priority=planning_priority, + planning_order=planning_order, + tool_name="create_workstream", + ) @mcp.tool() def create_task( - workstream_id: str, - title: str, + workplan_id: str | None = None, + workstream_id: str | None = None, + title: str = "", priority: str = "medium", description: str | None = None, assignee: str | None = None, @@ -525,15 +646,19 @@ def create_task( """Create a new task and emit a progress_event. Args: - workstream_id: UUID of the parent workstream + workplan_id: UUID of the parent workplan (preferred) + workstream_id: legacy alias for workplan_id title: task title priority: low | medium | high | critical description: optional longer description assignee: optional assignee name due_date: optional ISO date string (YYYY-MM-DD) """ + parent_id = workplan_id or workstream_id + if not parent_id: + return _json_result(_mcp_error("create_task", "workplan_id is required")) task = _post("/tasks", { - "workstream_id": workstream_id, + "workplan_id": parent_id, "title": title, "priority": priority, "description": description, @@ -544,7 +669,8 @@ def create_task( return _json_result(error) progress_error = _emit_progress_event("create_task", task, { - "workstream_id": workstream_id, + "workplan_id": parent_id, + "workstream_id": parent_id, "task_id": task["id"], "event_type": "task_created", "summary": f"Task created: {title}", @@ -865,27 +991,81 @@ def add_progress_event( @mcp.tool() -def update_workstream_status(workstream_id: str, status: str) -> str: - """Update a workstream's status. +def list_workplans( + repo_id: str | None = None, + topic_id: str | None = None, + status: str | None = None, + owner: str | None = None, + slug: str | None = None, +) -> str: + """List workplans with optional filters.""" + return json.dumps( + _get("/workplans", { + "repo_id": repo_id, + "topic_id": topic_id, + "status": status, + "owner": owner, + "slug": slug, + }), + indent=2, + ) + + +@mcp.tool() +def list_workstreams( + topic_id: str | None = None, + repo_id: str | None = None, + status: str | None = None, + owner: str | None = None, + slug: str | None = None, +) -> str: + """Legacy alias for list_workplans.""" + return list_workplans( + repo_id=repo_id, + topic_id=topic_id, + status=status, + owner=owner, + slug=slug, + ) + + +@mcp.tool() +def update_workplan_status(workplan_id: str, status: str) -> str: + """Update a workplan's status. Args: - workstream_id: UUID of the workstream + workplan_id: UUID of the workplan status: proposed | ready | active | blocked | backlog | finished | archived """ - ws = _patch(f"/workstreams/{workstream_id}", {"status": status}) - if error := _response_error("update_workstream_status", ws, ("id", "title")): - return _json_result(error) + return _update_workplan_status_impl(workplan_id, status, tool_name="update_workplan_status") - progress_error = _emit_progress_event("update_workstream_status", ws, { - "workstream_id": workstream_id, - "topic_id": ws.get("topic_id"), - "event_type": "workstream_status_changed", - "summary": f"Workstream status → {status}: {ws['title']}", - "author": "custodian", - }) - if progress_error: - return _json_result(progress_error) - return _json_result(ws) + +@mcp.tool() +def update_workstream_status(workstream_id: str, status: str) -> str: + """Legacy alias for update_workplan_status.""" + return _update_workplan_status_impl(workstream_id, status, tool_name="update_workstream_status") + + +@mcp.tool() +def update_workplan( + workplan_id: str, + title: str | None = None, + description: str | None = None, + owner: str | None = None, + due_date: str | None = None, + repo_goal_id: str | None = None, + status: str | None = None, +) -> str: + """Update fields on an existing workplan.""" + return _update_workplan_impl( + workplan_id, + title=title, + description=description, + owner=owner, + due_date=due_date, + repo_goal_id=repo_goal_id, + status=status, + ) @mcp.tool() @@ -898,32 +1078,16 @@ def update_workstream( repo_goal_id: str | None = None, status: str | None = None, ) -> str: - """Update fields on an existing workstream. - - Args: - workstream_id: UUID of the workstream - title: new title (optional) - description: new description (optional) - owner: new owner (optional) - due_date: ISO date string YYYY-MM-DD (optional) - repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear) - status: proposed | ready | active | blocked | backlog | finished | archived (optional) - """ - payload: dict = {} - if title is not None: - payload["title"] = title - if description is not None: - payload["description"] = description - if owner is not None: - payload["owner"] = owner - if due_date is not None: - payload["due_date"] = due_date - if status is not None: - payload["status"] = status - if repo_goal_id is not None: - payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None - ws = _patch(f"/workstreams/{workstream_id}", payload) - return json.dumps(ws, indent=2) + """Legacy alias for update_workplan.""" + return _update_workplan_impl( + workstream_id, + title=title, + description=description, + owner=owner, + due_date=due_date, + repo_goal_id=repo_goal_id, + status=status, + ) # --------------------------------------------------------------------------- @@ -951,6 +1115,41 @@ def get_next_steps() -> str: # Dependency graph tools (S1.4) # --------------------------------------------------------------------------- +def _create_dependency_impl( + *, + from_workplan_id: str, + to_workplan_id: str | None = None, + to_task_id: str | None = None, + relationship_type: str = "blocks", + description: str | None = None, +) -> str: + dep = _post(f"/workplans/{from_workplan_id}/dependencies", { + "to_workplan_id": to_workplan_id, + "to_task_id": to_task_id, + "relationship_type": relationship_type, + "description": description, + }) + return json.dumps(dep, indent=2) + + +@mcp.tool() +def create_workplan_dependency( + from_workplan_id: str, + to_workplan_id: str | None = None, + to_task_id: str | None = None, + relationship_type: str = "blocks", + description: str | None = None, +) -> str: + """Record that one workplan depends on another workplan or task.""" + return _create_dependency_impl( + from_workplan_id=from_workplan_id, + to_workplan_id=to_workplan_id, + to_task_id=to_task_id, + relationship_type=relationship_type, + description=description, + ) + + @mcp.tool() def create_dependency( from_workstream_id: str, @@ -959,25 +1158,14 @@ def create_dependency( relationship_type: str = "blocks", description: str | None = None, ) -> str: - """Record that one workstream depends on another workstream or task. - - Semantics: from_workstream cannot fully proceed until the target reaches - a satisfactory state. Provide exactly one of to_workstream_id or to_task_id. - - Args: - from_workstream_id: UUID of the workstream that has the dependency - to_workstream_id: UUID of the workstream it depends on - to_task_id: UUID of the task it depends on - relationship_type: blocks | starts_after | informs | soft_dependency - description: optional human-readable explanation of the dependency - """ - dep = _post(f"/workstreams/{from_workstream_id}/dependencies", { - "to_workstream_id": to_workstream_id, - "to_task_id": to_task_id, - "relationship_type": relationship_type, - "description": description, - }) - return json.dumps(dep, indent=2) + """Legacy alias for create_workplan_dependency.""" + return _create_dependency_impl( + from_workplan_id=from_workstream_id, + to_workplan_id=to_workstream_id, + to_task_id=to_task_id, + relationship_type=relationship_type, + description=description, + ) @mcp.tool() @@ -990,9 +1178,15 @@ def list_dependencies(workstream_id: str) -> str: Args: workstream_id: UUID of the workstream to inspect """ - edges = _get(f"/workstreams/{workstream_id}/dependencies") - depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id] - blocks = [e for e in edges if e.get("to_workstream_id") == workstream_id] + edges = _get(f"/workplans/{workstream_id}/dependencies") + depends_on = [ + e for e in edges + if e.get("from_workplan_id", e.get("from_workstream_id")) == workstream_id + ] + blocks = [ + e for e in edges + if e.get("to_workplan_id", e.get("to_workstream_id")) == workstream_id + ] return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2) @@ -1227,13 +1421,48 @@ def archive_domain(slug: str) -> str: @mcp.tool() -def list_domain_repos(domain_slug: str) -> str: - """List all repositories registered under a domain. +def list_domain_repos( + domain_slug: str, + category: str | None = None, + capability_tag: str | None = None, + business_stake: str | None = None, +) -> str: + """List repositories registered under a domain, with optional classification filters. Args: domain_slug: Domain slug to filter by + category: optional repo classification category + capability_tag: optional capability tag filter + business_stake: optional business stake filter """ - return json.dumps(_get("/repos", {"domain": domain_slug}), indent=2) + return json.dumps( + _get("/repos", { + "domain": domain_slug, + "category": category, + "capability_tag": capability_tag, + "business_stake": business_stake, + }), + indent=2, + ) + + +@mcp.tool() +def list_repos_by_classification( + category: str | None = None, + domain: str | None = None, + capability_tag: str | None = None, + business_stake: str | None = None, +) -> str: + """List repos filtered by classification spine fields.""" + return json.dumps( + _get("/repos", { + "domain": domain, + "category": category, + "capability_tag": capability_tag, + "business_stake": business_stake, + }), + indent=2, + ) @mcp.tool() @@ -1275,6 +1504,60 @@ def register_repo( return json.dumps(repo, indent=2) +@mcp.tool() +def register_repo_from_classification( + repo_slug: str, + dry_run: bool = False, +) -> str: + """Register or update a repo from its committed ``.repo-classification.yaml``. + + Reads the classification file from the repo's local checkout (this host's + registered path), validates against the canon allowed-values, and upserts the + ``managed_repo`` row including market-domain assignment. + + Args: + repo_slug: Registered repo slug (e.g. 'state-hub', 'the-custodian'). + dry_run: If True, report what would change without writing. + """ + import subprocess + + script = Path(__file__).parent.parent / "scripts" / "register_from_classification.py" + cmd = [ + sys.executable, + str(script), + "--slug", + repo_slug, + "--json", + ] + if dry_run: + cmd.append("--dry-run") + + result = subprocess.run(cmd, capture_output=True, text=True) + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return ( + f"register-from-classification failed (exit {result.returncode}):\n" + f"{result.stderr or result.stdout or '(no output)'}" + ) + + summary = data.get("summary", {}) + lines = [ + f"register-from-classification: {repo_slug}", + ( + f"registered={summary.get('registered', 0)} " + f"updated={summary.get('updated', 0)} " + f"skipped={summary.get('skipped', 0)} " + f"invalid={summary.get('invalid', 0)}" + ), + ] + for row in data.get("results", []): + lines.append(f" [{row.get('outcome')}] {row.get('detail', '')}") + if result.returncode != 0: + lines.append("(completed with invalid rows)") + return "\n".join(lines) + + @mcp.tool() def update_repo_path(repo_slug: str, path: str, host: str | None = None) -> str: """Register or update the local filesystem path for a repo on a specific host. diff --git a/migrations/versions/d8e9f0a1b2c3_repo_anchored_classification_spine.py b/migrations/versions/d8e9f0a1b2c3_repo_anchored_classification_spine.py new file mode 100644 index 0000000..8cdea1c --- /dev/null +++ b/migrations/versions/d8e9f0a1b2c3_repo_anchored_classification_spine.py @@ -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", + ) \ No newline at end of file diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index e26536c..83be0bd 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -26,6 +26,7 @@ Checks: C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph C-22 task-description-drift WARN Yes Task description/content differs between file and DB C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is progress or wait + C-24 repo-classification-missing WARN No Registered repo lacks a valid .repo-classification.yaml on disk Usage: python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] @@ -42,7 +43,7 @@ Exit codes (--remote --all scheduled sweep): 1 — automation error: API unreachable, repo list fetch failed, C-00 on any repo, or other infrastructure fault that prevented a full run -Assessment failures (C-01..C-23 except C-00) are repo hygiene gaps recorded +Assessment failures (C-01..C-24 except C-00) are repo hygiene gaps recorded in the sweep report for later improvement. They do not fail the scheduler. Agent/operator Make wrappers normalize exit code 2 to shell success while @@ -78,6 +79,11 @@ from api.workplan_status import ( # noqa: E402 normalize_workstream_status as _normalize_workstream_status, ready_review_status, ) +from api.classification import ( # noqa: E402 + CLASSIFICATION_FILENAME, + load_classification_file, + resolve_topic_domain_slug, +) from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402 from api.task_status import ( # noqa: E402 CANONICAL_TASK_STATUSES, @@ -713,6 +719,31 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N repo_dir = Path(repo_path) workplans_dir = repo_dir / "workplans" + repo_market_domain = str(repo.get("domain_slug") or "").strip() + + # C-24: repo classification file missing or invalid (always WARN — migration rows too) + class_data, class_errors, class_warnings = load_classification_file(repo_dir) + if class_data is None: + classified_by = str(repo.get("classified_by") or "").strip() + if class_errors: + detail = "; ".join(class_errors) + else: + detail = f"{CLASSIFICATION_FILENAME} missing on disk" + if classified_by == "migration": + detail = f"{detail} (DB row is migration-derived — commit a human-reviewed file when ready)" + report.add( + severity="WARN", + check_id="C-24", + message=f"Repo classification gap: {detail}", + fixable=False, + ) + for warning in class_warnings: + report.add( + severity="WARN", + check_id="C-24", + message=f"Repo classification advisory: {warning}", + fixable=False, + ) # C-01: workplans/ directory missing if not workplans_dir.is_dir(): @@ -804,6 +835,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N "body": body, "repo_id": repo_id, "domain": file_domain, + "repo_market_domain": repo_market_domain, }, ) continue @@ -1708,6 +1740,7 @@ def fix_repo( wp_file = Path(ctx["wp_file"]) meta = ctx["meta"] domain = ctx["domain"] + repo_market_domain = str(ctx.get("repo_market_domain") or "").strip() repo_id_val = ctx["repo_id"] body = ctx.get("body", "") wp_id = str(meta.get("id", "")).strip() @@ -1717,17 +1750,23 @@ def fix_repo( if status not in VALID_WP_STATUSES: status = "active" - # Find topic_id for this domain + # Find topic_id — workplan frontmatter may still use legacy + # coordination slugs (e.g. custodian); map to market domain first. + topic_domain = resolve_topic_domain_slug( + domain, + repo_market_domain=repo_market_domain or None, + ) topics = _api_get(api_base, "/topics") topic_id = None if isinstance(topics, list): for t in topics: - if t.get("domain_slug") == domain: + if t.get("domain_slug") == topic_domain: topic_id = t["id"] break if topic_id is None: report.fixes_applied.append( - f"C-06 SKIP {wp_id}: no topic found for domain '{domain}'" + f"C-06 SKIP {wp_id}: no topic found for domain " + f"'{topic_domain}' (workplan domain={domain!r})" ) continue diff --git a/scripts/register_from_classification.py b/scripts/register_from_classification.py new file mode 100644 index 0000000..aa99be5 --- /dev/null +++ b/scripts/register_from_classification.py @@ -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()) \ No newline at end of file diff --git a/scripts/spine_migration_data.py b/scripts/spine_migration_data.py new file mode 100644 index 0000000..5d6853b --- /dev/null +++ b/scripts/spine_migration_data.py @@ -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, + } \ No newline at end of file diff --git a/scripts/spine_migration_dry_run.py b/scripts/spine_migration_dry_run.py new file mode 100644 index 0000000..d8824f4 --- /dev/null +++ b/scripts/spine_migration_dry_run.py @@ -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()) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 98fd422..72848ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -102,3 +102,62 @@ async def client(test_engine): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Shared entity helpers (workplan-first; legacy workstream names retained) +# --------------------------------------------------------------------------- + +async def create_test_domain(client, slug="infotech", name="Infotech"): + r = await client.post("/domains/", json={"slug": slug, "name": name}) + assert r.status_code == 201, r.text + return r.json() + + +async def create_test_topic(client, domain_slug="infotech", slug="testtopic", title="Test Topic"): + r = await client.post("/topics/", json={ + "slug": slug, "title": title, "domain": domain_slug, + }) + assert r.status_code == 201, r.text + return r.json() + + +async def create_test_repo(client, domain_slug="infotech", slug="test-repo", **extra): + payload = { + "domain_slug": domain_slug, + "slug": slug, + "name": "Test Repo", + **extra, + } + r = await client.post("/repos/", json=payload) + assert r.status_code == 201, r.text + return r.json() + + +async def create_test_workplan( + client, + repo_id, + topic_id=None, + slug="test-wp", + title="Test Workplan", + status="active", + **extra, +): + payload = {"repo_id": repo_id, "slug": slug, "title": title, "status": status, **extra} + if topic_id is not None: + payload["topic_id"] = topic_id + r = await client.post("/workplans/", json=payload) + assert r.status_code == 201, r.text + return r.json() + + +async def create_test_workstream(client, topic_id=None, repo_id=None, slug="test-wp", **kwargs): + """Legacy helper name — creates a repo-anchored workplan.""" + if repo_id is None: + domain = await create_test_domain(client) + if topic_id is None: + topic = await create_test_topic(client, domain_slug=domain["slug"]) + topic_id = topic["id"] + repo = await create_test_repo(client, domain_slug=domain["slug"], slug=f"{slug}-repo") + repo_id = repo["id"] + return await create_test_workplan(client, repo_id=repo_id, topic_id=topic_id, slug=slug, **kwargs) diff --git a/tests/test_capability_requests.py b/tests/test_capability_requests.py index b7d1818..1d2a0c8 100644 --- a/tests/test_capability_requests.py +++ b/tests/test_capability_requests.py @@ -27,17 +27,19 @@ async def _create_topic(client, domain_slug="testdomain"): return r.json() -async def _create_workstream(client, topic_id): - r = await client.post("/workstreams/", json={ - "topic_id": topic_id, "slug": "test-ws", "title": "Test WS", - }) - assert r.status_code == 201, r.text - return r.json() +from tests.conftest import create_test_repo, create_test_workplan -async def _create_task(client, workstream_id, title="Test task", status="wait"): +async def _create_workstream(client, topic_id, domain_slug="custodian"): + repo = await create_test_repo(client, domain_slug=domain_slug, slug="test-repo") + return await create_test_workplan( + client, repo_id=repo["id"], topic_id=topic_id, slug="test-ws", title="Test WS", + ) + + +async def _create_task(client, workplan_id, title="Test task", status="wait"): r = await client.post("/tasks/", json={ - "workstream_id": workstream_id, "title": title, + "workplan_id": workplan_id, "title": title, }) assert r.status_code == 201, r.text task = r.json() diff --git a/tests/test_classification.py b/tests/test_classification.py new file mode 100644 index 0000000..83de8f7 --- /dev/null +++ b/tests/test_classification.py @@ -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" \ No newline at end of file diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index d8b28af..5d91941 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -23,6 +23,7 @@ import pytest # Make scripts/ importable without installing sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) +from api.classification import resolve_topic_domain_slug from consistency_check import ( ConsistencyReport, Issue, @@ -54,6 +55,15 @@ from api.workplan_status import ready_review_status # for backward compat; their canonical implementations live in repo_sync.py. +# --------------------------------------------------------------------------- +# domain mapping (STATE-WP-0065 P4) +# --------------------------------------------------------------------------- + +class TestResolveTopicDomainSlug: + def test_workplan_custodian_maps_to_infotech(self): + assert resolve_topic_domain_slug("custodian", repo_market_domain="infotech") == "infotech" + + # --------------------------------------------------------------------------- # parse_frontmatter # --------------------------------------------------------------------------- @@ -372,7 +382,7 @@ class TestRenderText: r.add(severity="WARN", check_id="C-04", message="w") r.add(severity="INFO", check_id="C-08", message="i") text = render_text(r) - assert "1 fail" in text + assert "1 assessment-fail" in text assert "1 warn" in text assert "1 info" in text @@ -443,7 +453,7 @@ class TestReportToDict: r = ConsistencyReport(repo_slug="r", repo_path="/p") d = report_to_dict(r) assert d["result"] == "pass" - assert d["summary"] == {"fail": 0, "warn": 0, "info": 0} + assert d["summary"] == {"fail": 0, "automation_error": 0, "warn": 0, "info": 0} assert d["issues"] == [] def test_fail_result(self): diff --git a/tests/test_legacy_meter.py b/tests/test_legacy_meter.py index 6b5f1ca..f844ac6 100644 --- a/tests/test_legacy_meter.py +++ b/tests/test_legacy_meter.py @@ -17,8 +17,20 @@ async def _create_topic(client, domain_slug="legacy-domain", slug="legacy-topic" return r.json() -async def _create_workplan(client, topic_id, slug="legacy-wp", title="Legacy WP"): +async def _create_repo(client, domain_slug="legacy-domain", slug="legacy-repo"): + r = await client.post("/repos/", json={ + "domain_slug": domain_slug, + "slug": slug, + "name": "Legacy Repo", + }) + assert r.status_code == 201, r.text + return r.json() + + +async def _create_workplan(client, topic_id, domain_slug="legacy-domain", slug="legacy-wp", title="Legacy WP"): + repo = await _create_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo") r = await client.post("/workplans/", json={ + "repo_id": repo["id"], "topic_id": topic_id, "slug": slug, "title": title, diff --git a/tests/test_mcp_smoke.py b/tests/test_mcp_smoke.py index 86bfc68..960fecf 100644 --- a/tests/test_mcp_smoke.py +++ b/tests/test_mcp_smoke.py @@ -28,12 +28,14 @@ async def _create_topic(client, domain_slug="mcp-domain"): return r.json() -async def _create_workstream(client, topic_id): - r = await client.post("/workstreams/", json={ - "topic_id": topic_id, "slug": "mcp-ws", "title": "MCP Workstream", - }) - assert r.status_code == 201 - return r.json() +from tests.conftest import create_test_repo, create_test_workplan + + +async def _create_workstream(client, topic_id, domain_slug="mcp-domain"): + repo = await create_test_repo(client, domain_slug=domain_slug, slug="mcp-repo") + return await create_test_workplan( + client, repo_id=repo["id"], topic_id=topic_id, slug="mcp-ws", title="MCP Workstream", + ) # --------------------------------------------------------------------------- diff --git a/tests/test_mcp_write_tools.py b/tests/test_mcp_write_tools.py index 67c7813..c0034bc 100644 --- a/tests/test_mcp_write_tools.py +++ b/tests/test_mcp_write_tools.py @@ -19,15 +19,16 @@ async def _call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: class TestMCPWriteTools: - async def test_create_workstream_returns_rest_shape_and_emits_progress(self, monkeypatch): + async def test_create_workplan_returns_rest_shape_and_emits_progress(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: calls.append((path, body)) - if path == "/workstreams": + if path == "/workplans": return { - "id": "ws-1", - "topic_id": body["topic_id"], + "id": "wp-1", + "repo_id": body["repo_id"], + "topic_id": body.get("topic_id"), "title": body["title"], "slug": body["slug"], "status": body["status"], @@ -39,20 +40,42 @@ class TestMCPWriteTools: monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( - "create_workstream", - {"topic_id": "topic-1", "title": "MCP Reliable Write"}, + "create_workplan", + {"repo_id": "repo-1", "topic_id": "topic-1", "title": "MCP Reliable Write"}, ) assert body == { - "id": "ws-1", + "id": "wp-1", + "repo_id": "repo-1", "topic_id": "topic-1", "title": "MCP Reliable Write", "slug": "mcp-reliable-write", "status": "active", } - assert [path for path, _ in calls] == ["/workstreams", "/progress"] - assert calls[1][1]["workstream_id"] == "ws-1" - assert calls[1][1]["event_type"] == "workstream_created" + assert [path for path, _ in calls] == ["/workplans", "/progress"] + assert calls[1][1]["workplan_id"] == "wp-1" + assert calls[1][1]["event_type"] == "workplan_created" + + async def test_create_workstream_legacy_alias_uses_workplans_endpoint(self, monkeypatch): + calls: list[tuple[str, dict[str, Any]]] = [] + + def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: + calls.append((path, body)) + if path == "/workplans": + return {"id": "wp-1", "repo_id": body["repo_id"], "title": body["title"], "slug": body["slug"], "status": "active"} + if path == "/progress": + return {"id": "event-1", **body} + raise AssertionError(f"unexpected POST {path}") + + monkeypatch.setattr(server, "_post", fake_post) + + body = await _call_tool( + "create_workstream", + {"repo_id": "repo-1", "title": "Legacy alias"}, + ) + + assert body["id"] == "wp-1" + assert [path for path, _ in calls] == ["/workplans", "/progress"] async def test_create_task_returns_rest_shape_and_emits_progress(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] @@ -62,7 +85,8 @@ class TestMCPWriteTools: if path == "/tasks": return { "id": "task-1", - "workstream_id": body["workstream_id"], + "workplan_id": body.get("workplan_id") or body.get("workstream_id"), + "workstream_id": body.get("workplan_id") or body.get("workstream_id"), "title": body["title"], "priority": body["priority"], "status": "todo", @@ -80,6 +104,7 @@ class TestMCPWriteTools: assert body == { "id": "task-1", + "workplan_id": "ws-1", "workstream_id": "ws-1", "title": "MCP task", "priority": "high", @@ -266,18 +291,18 @@ class TestMCPWriteTools: def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]: calls.append((path, body)) - return {"error": "API 422: invalid topic"} + return {"error": "API 422: invalid repo"} monkeypatch.setattr(server, "_post", fake_post) body = await _call_tool( "create_workstream", - {"topic_id": "bad-topic", "title": "No progress on failure"}, + {"repo_id": "bad-repo", "title": "No progress on failure"}, ) assert body["tool"] == "create_workstream" - assert body["error"] == "API 422: invalid topic" - assert [path for path, _ in calls] == ["/workstreams"] + assert body["error"] == "API 422: invalid repo" + assert [path for path, _ in calls] == ["/workplans"] async def test_record_decision_missing_id_is_clear_and_skips_progress(self, monkeypatch): calls: list[tuple[str, dict[str, Any]]] = [] diff --git a/tests/test_recently_on_scope.py b/tests/test_recently_on_scope.py index ba43e96..22ed71a 100644 --- a/tests/test_recently_on_scope.py +++ b/tests/test_recently_on_scope.py @@ -20,14 +20,18 @@ async def _create_topic(client, domain_slug="digest", slug="digest-topic", title return response.json() -async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream"): - response = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": title}) - assert response.status_code == 201, response.text - return response.json() +from tests.conftest import create_test_repo, create_test_workplan -async def _create_task(client, workstream_id, title="Digest task"): - response = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title}) +async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream", domain_slug="digest"): + repo = await create_test_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo") + return await create_test_workplan( + client, repo_id=repo["id"], topic_id=topic_id, slug=slug, title=title, + ) + + +async def _create_task(client, workplan_id, title="Digest task"): + response = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": title}) assert response.status_code == 201, response.text return response.json() @@ -277,12 +281,16 @@ class TestRecentlyOnScopeRoutes: await _create_domain(client, slug="broken", name="Broken Domain") broken_topic = await _create_topic(client, domain_slug="broken", slug="broken-topic") - broken_workstream = await _create_workstream(client, broken_topic["id"], slug="broken-ws") + broken_workstream = await _create_workstream( + client, broken_topic["id"], slug="broken-ws", domain_slug="broken", + ) await _create_task(client, broken_workstream["id"], title="Broken source") await _create_domain(client, slug="good", name="Good Domain") good_topic = await _create_topic(client, domain_slug="good", slug="good-topic") - good_workstream = await _create_workstream(client, good_topic["id"], slug="good-ws") + good_workstream = await _create_workstream( + client, good_topic["id"], slug="good-ws", domain_slug="good", + ) await _create_task(client, good_workstream["id"], title="Good source") response = await client.post("/recently-on-scope/hourly", json={"range": "1d"}) diff --git a/tests/test_register_from_classification.py b/tests/test_register_from_classification.py new file mode 100644 index 0000000..9b7a6ac --- /dev/null +++ b/tests/test_register_from_classification.py @@ -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"} \ No newline at end of file diff --git a/tests/test_routers_core.py b/tests/test_routers_core.py index aac9e53..961fbd2 100644 --- a/tests/test_routers_core.py +++ b/tests/test_routers_core.py @@ -28,19 +28,34 @@ async def _create_topic(client, domain_slug="testdomain", slug="testtopic", titl return r.json() -async def _create_workstream(client, topic_id, slug="test-ws", title="Test WS", status="active", **extra): +async def _create_workplan(client, repo_id, topic_id=None, slug="test-ws", title="Test WS", status="active", **extra): payload = { - "topic_id": topic_id, "slug": slug, "title": title, "status": status, + "repo_id": repo_id, "slug": slug, "title": title, "status": status, } + if topic_id is not None: + payload["topic_id"] = topic_id payload.update(extra) - r = await client.post("/workstreams/", json=payload) + r = await client.post("/workplans/", json=payload) assert r.status_code == 201, r.text return r.json() -async def _create_task(client, workstream_id, title="Test task"): +async def _create_workstream(client, topic_id=None, repo_id=None, slug="test-ws", title="Test WS", status="active", **extra): + if repo_id is None: + if topic_id is None: + await _create_domain(client) + topic = await _create_topic(client) + topic_id = topic["id"] + repo = await _create_repo(client, slug=f"{slug}-repo") + repo_id = repo["id"] + return await _create_workplan( + client, repo_id=repo_id, topic_id=topic_id, slug=slug, title=title, status=status, **extra + ) + + +async def _create_task(client, workplan_id, title="Test task"): r = await client.post("/tasks/", json={ - "workstream_id": workstream_id, "title": title, + "workplan_id": workplan_id, "title": title, }) assert r.status_code == 201, r.text return r.json() diff --git a/tests/test_task_bulk_status_sync.py b/tests/test_task_bulk_status_sync.py index 50415b9..8a5d6ea 100644 --- a/tests/test_task_bulk_status_sync.py +++ b/tests/test_task_bulk_status_sync.py @@ -16,19 +16,20 @@ async def _create_topic(client, domain_slug: str = "bulk-domain"): return r.json() -async def _create_workstream(client, topic_id: str): - r = await client.post( - "/workstreams/", - json={"topic_id": topic_id, "slug": "bulk-ws", "title": "Bulk Workstream"}, +from tests.conftest import create_test_repo, create_test_workplan + + +async def _create_workstream(client, topic_id: str, domain_slug: str = "bulk-domain"): + repo = await create_test_repo(client, domain_slug=domain_slug, slug="bulk-repo") + return await create_test_workplan( + client, repo_id=repo["id"], topic_id=topic_id, slug="bulk-ws", title="Bulk Workstream", ) - assert r.status_code == 201 - return r.json() -async def _create_task(client, workstream_id: str, title: str): +async def _create_task(client, workplan_id: str, title: str): r = await client.post( "/tasks/", - json={"workstream_id": workstream_id, "title": title}, + json={"workplan_id": workplan_id, "title": title}, ) assert r.status_code == 201 return r.json() diff --git a/tests/test_token_events.py b/tests/test_token_events.py index d392373..ac5dd45 100644 --- a/tests/test_token_events.py +++ b/tests/test_token_events.py @@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="testdomain"): return r.json() -async def _create_workstream(client, topic_id, slug="ws1"): - r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": "WS"}) - assert r.status_code == 201, r.text - return r.json() +from tests.conftest import create_test_repo, create_test_workplan -async def _create_task(client, workstream_id): - r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": "task"}) +async def _create_workstream(client, topic_id, slug="ws1", domain_slug="testdomain"): + repo = await create_test_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo") + return await create_test_workplan(client, repo_id=repo["id"], topic_id=topic_id, slug=slug, title="WS") + + +async def _create_task(client, workplan_id): + r = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": "task"}) assert r.status_code == 201, r.text return r.json() diff --git a/tests/test_token_passthrough.py b/tests/test_token_passthrough.py index fa10184..bf433d3 100644 --- a/tests/test_token_passthrough.py +++ b/tests/test_token_passthrough.py @@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="td"): return r.json() -async def _create_workstream(client, topic_id): - r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": "ws", "title": "WS"}) - assert r.status_code == 201, r.text - return r.json() +from tests.conftest import create_test_repo, create_test_workplan -async def _create_task(client, workstream_id, title="my task"): - r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title}) +async def _create_workstream(client, topic_id, domain_slug="td"): + repo = await create_test_repo(client, domain_slug=domain_slug, slug="td-repo") + return await create_test_workplan(client, repo_id=repo["id"], topic_id=topic_id, slug="ws", title="WS") + + +async def _create_task(client, workplan_id, title="my task"): + r = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": title}) assert r.status_code == 201, r.text return r.json() diff --git a/workplans/STATE-WP-0065-repo-anchored-classification-spine.md b/workplans/STATE-WP-0065-repo-anchored-classification-spine.md index 8f2ea55..6951b5f 100644 --- a/workplans/STATE-WP-0065-repo-anchored-classification-spine.md +++ b/workplans/STATE-WP-0065-repo-anchored-classification-spine.md @@ -4,11 +4,13 @@ type: workplan title: "Repo-anchored classification spine (CUST-WP-0050 implementation)" domain: custodian repo: state-hub -status: ready +status: finished owner: custodian topic_slug: custodian created: "2026-06-22" updated: "2026-06-22" +started: "2026-06-22" +finished: "2026-06-22" state_hub_workstream_id: "8dc7d106-11e2-41df-b512-89ed69d2a65f" --- @@ -47,7 +49,7 @@ repo-owned files (ADR-001). ```task id: STATE-WP-0065-T01 -status: todo +status: done priority: high state_hub_task_id: "14cf65f1-e5af-4905-8de4-bc8986ef078e" ``` @@ -82,7 +84,7 @@ dry-run report is reviewed, and a tested downgrade path exists. ```task id: STATE-WP-0065-T02 -status: todo +status: done priority: high state_hub_task_id: "d3afcae1-d47e-42f1-bad8-1de4bd1f126a" ``` @@ -103,7 +105,7 @@ are green. ```task id: STATE-WP-0065-T03 -status: todo +status: done priority: high state_hub_task_id: "bab90f0c-238e-4f43-b34c-a8cdd8faf0e6" ``` @@ -122,7 +124,7 @@ emits a report of registered / updated / skipped / invalid. ```task id: STATE-WP-0065-T04 -status: todo +status: done priority: medium state_hub_task_id: "67c54009-823d-466b-beaf-f27351c279f4" ```