generated from coulomb/repo-seed
feat(classification-spine): implement STATE-WP-0065 repo-anchored model
Replace the ad-hoc coordination-domain spine with the Repo Classification Standard: 14 market domains, classification columns on managed_repos, and workplans anchored by repo_id (topic_id optional). - Add Alembic migration d8e9f0a1b2c3 with data backfill and workstream→workplan rename - Add api/classification.py validation and register-from-classification tooling - Expose workplan-first REST/MCP surface with legacy workstream aliases - Add C-24 consistency rule and legacy domain frontmatter mapping - Update dashboard repos page with category/capability/stake filters - Update orientation docs; mark STATE-WP-0065 finished
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
## Architecture
|
||||
|
||||
<!-- TODO: Describe the key design decisions and component structure.
|
||||
Key modules, data flows, external integrations, state machines, etc. -->
|
||||
State Hub uses a **repo-anchored classification spine** (STATE-WP-0065):
|
||||
|
||||
- **Primary anchor:** `managed_repos` + committed `.repo-classification.yaml`
|
||||
- **Market domain:** derived from classification (`domain` field) — 14 fixed values
|
||||
- **Workplans:** table `workplans`, `repo_id` required, `topic_id` optional
|
||||
- **Legacy:** `/workstreams/` REST routes and `workstream_*` MCP tools are aliases
|
||||
|
||||
Classification canon lives in `the-custodian/canon/standards/`.
|
||||
Validation: `api/classification.py` · registration: `make register-from-classification`.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
17
Makefile
17
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
|
||||
|
||||
40
README.md
40
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)
|
||||
└── progress_events
|
||||
decisions (FK: topic_id, workstream_id — at least one required)
|
||||
domains (14 market domains: infotech, financials, communication, …)
|
||||
managed_repos (classification: category, domain, capability_tags, business_stake, …)
|
||||
└── workplans (repo_id required; topic_id optional legacy tag)
|
||||
└── tasks
|
||||
└── progress_events
|
||||
topics (optional cross-repo tag; domain_id → market domain)
|
||||
decisions (FK: topic_id and/or workplan_id)
|
||||
```
|
||||
|
||||
### Enums
|
||||
Each registered repo carries a committed `.repo-classification.yaml` (canon
|
||||
standard v1.0). Registration and reclassification use
|
||||
`make register-from-classification`.
|
||||
|
||||
| Enum | Values |
|
||||
### Key enums / vocabularies
|
||||
|
||||
| Field | Values |
|
||||
|------|--------|
|
||||
| `topic_status` | `active` · `paused` · `archived` |
|
||||
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||
| `workplan_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
|
||||
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
||||
| `decision_type` | `made` · `pending` |
|
||||
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
|
||||
| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` |
|
||||
| `repo category` | `experimental` · `research` · `project` · `tooling` · `product` · `business` |
|
||||
| `market domain` | 14 fixed slugs — see `the-custodian/canon/standards/repo-classification.allowed.yaml` |
|
||||
|
||||
### Governance constraints encoded in schema
|
||||
|
||||
@@ -226,9 +230,11 @@ See `mcp_server/TOOLS.md` for the full tool reference card (30 lines, faster tha
|
||||
|
||||
**Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress`
|
||||
|
||||
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `update_workstream_status`
|
||||
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `create_workplan` · `update_workplan_status` · `register_repo_from_classification`
|
||||
|
||||
**Resources**: `state://summary` · `state://topics` · `state://workstreams/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
|
||||
**Resources**: `state://summary` · `state://topics` · `state://workplans/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
|
||||
|
||||
Legacy `workstream_*` tool names remain as aliases — see `mcp_server/TOOLS.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
9
SCOPE.md
9
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
|
||||
|
||||
290
api/classification.py
Normal file
290
api/classification.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Repo classification validation for State Hub registration (STATE-WP-0065 P1).
|
||||
|
||||
Loads allowed values from the custodian canon standard and validates classification
|
||||
blocks against controlled vocabularies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# Primary path (sibling checkout); fallback relative to state-hub repo root.
|
||||
_PRIMARY_ALLOWED = Path(
|
||||
"/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"
|
||||
)
|
||||
_FALLBACK_ALLOWED = (
|
||||
Path(__file__).resolve().parent.parent.parent
|
||||
/ "the-custodian"
|
||||
/ "canon"
|
||||
/ "standards"
|
||||
/ "repo-classification.allowed.yaml"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationData:
|
||||
"""Normalized classification fields stored on ``managed_repos``."""
|
||||
|
||||
category: str
|
||||
domain: str
|
||||
secondary_domains: list[str] = field(default_factory=list)
|
||||
capability_tags: list[str] = field(default_factory=list)
|
||||
business_stake: list[str] = field(default_factory=list)
|
||||
business_mechanics: list[str] = field(default_factory=list)
|
||||
classified_at: str | None = None
|
||||
classified_by: str | None = None
|
||||
standard_version: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"category": self.category,
|
||||
"domain": self.domain,
|
||||
"secondary_domains": list(self.secondary_domains),
|
||||
"capability_tags": list(self.capability_tags),
|
||||
"business_stake": list(self.business_stake),
|
||||
"business_mechanics": list(self.business_mechanics),
|
||||
"classified_at": self.classified_at,
|
||||
"classified_by": self.classified_by,
|
||||
"standard_version": self.standard_version,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_block(cls, block: dict) -> ClassificationData:
|
||||
return cls(
|
||||
category=block["category"],
|
||||
domain=block["domain"],
|
||||
secondary_domains=list(block.get("secondary_domains") or []),
|
||||
capability_tags=list(block.get("capability_tags") or []),
|
||||
business_stake=list(block.get("business_stake") or []),
|
||||
business_mechanics=list(block.get("business_mechanics") or []),
|
||||
classified_at=block.get("classified_at"),
|
||||
classified_by=block.get("classified_by"),
|
||||
standard_version=block.get("version") or block.get("standard_version"),
|
||||
)
|
||||
|
||||
|
||||
def _allowed_path() -> Path:
|
||||
if _PRIMARY_ALLOWED.is_file():
|
||||
return _PRIMARY_ALLOWED
|
||||
if _FALLBACK_ALLOWED.is_file():
|
||||
return _FALLBACK_ALLOWED
|
||||
raise FileNotFoundError(
|
||||
"repo-classification.allowed.yaml not found at "
|
||||
f"{_PRIMARY_ALLOWED} or {_FALLBACK_ALLOWED}"
|
||||
)
|
||||
|
||||
|
||||
def load_allowed_values(path: Path | None = None) -> dict:
|
||||
"""Load the machine-readable allowed-values YAML."""
|
||||
target = path or _allowed_path()
|
||||
with target.open(encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh)
|
||||
|
||||
|
||||
def _known_capability_tags(allowed: dict) -> set[str]:
|
||||
tags: set[str] = set()
|
||||
for fam in (allowed.get("capability_families") or {}).values():
|
||||
tags.update(fam or [])
|
||||
return tags
|
||||
|
||||
|
||||
def validate_classification(block: dict) -> tuple[list[str], list[str]]:
|
||||
"""Validate a ``repo_classification`` block.
|
||||
|
||||
Returns ``(errors, warnings)``. *block* should be the inner mapping (not the
|
||||
full YAML document with the ``repo_classification`` wrapper).
|
||||
"""
|
||||
allowed = load_allowed_values()
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not isinstance(block, dict):
|
||||
return (["classification block must be a mapping"], [])
|
||||
|
||||
categories = set(allowed["categories"])
|
||||
domains = set(allowed["domains"])
|
||||
stakes = set(allowed["business_stake"])
|
||||
mechanics = set(allowed["business_mechanics"])
|
||||
guidance = allowed.get("guidance", {})
|
||||
pattern = re.compile(
|
||||
guidance.get("capability_tag_pattern", r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
)
|
||||
|
||||
category = block.get("category")
|
||||
if category is None:
|
||||
errors.append("`category` is required")
|
||||
elif category not in categories:
|
||||
errors.append(f"`category` '{category}' not in {sorted(categories)}")
|
||||
|
||||
domain = block.get("domain")
|
||||
if domain is None:
|
||||
errors.append("`domain` is required")
|
||||
elif domain not in domains:
|
||||
errors.append(f"`domain` '{domain}' not in allowed domains")
|
||||
|
||||
secondary = block.get("secondary_domains") or []
|
||||
if not isinstance(secondary, list):
|
||||
errors.append("`secondary_domains` must be a list")
|
||||
secondary = []
|
||||
for d in secondary:
|
||||
if d not in domains:
|
||||
errors.append(f"secondary domain '{d}' not in allowed domains")
|
||||
if d == domain:
|
||||
errors.append(f"secondary domain '{d}' repeats the primary domain")
|
||||
if len(secondary) != len(set(secondary)):
|
||||
errors.append("`secondary_domains` contains duplicates")
|
||||
smax = guidance.get("secondary_domains_max", 3)
|
||||
if len(secondary) > smax:
|
||||
warnings.append(
|
||||
f"{len(secondary)} secondary_domains exceeds recommended max {smax}"
|
||||
)
|
||||
|
||||
tags = block.get("capability_tags") or []
|
||||
if not isinstance(tags, list):
|
||||
errors.append("`capability_tags` must be a list")
|
||||
tags = []
|
||||
known = _known_capability_tags(allowed)
|
||||
for t in tags:
|
||||
if not isinstance(t, str) or not pattern.match(t):
|
||||
errors.append(f"capability_tag '{t}' is not lowercase kebab-case")
|
||||
elif t not in known:
|
||||
warnings.append(
|
||||
f"capability_tag '{t}' is not a recommended family tag "
|
||||
"(allowed, check for synonym)"
|
||||
)
|
||||
|
||||
stake = block.get("business_stake") or []
|
||||
if not isinstance(stake, list):
|
||||
errors.append("`business_stake` must be a list")
|
||||
stake = []
|
||||
for s in stake:
|
||||
if s not in stakes:
|
||||
errors.append(f"business_stake '{s}' not in {sorted(stakes)}")
|
||||
if stake:
|
||||
lo = guidance.get("business_stake_recommended_min", 2)
|
||||
hi = guidance.get("business_stake_recommended_max", 6)
|
||||
if not (lo <= len(stake) <= hi):
|
||||
warnings.append(
|
||||
f"{len(stake)} business_stake values; {lo}-{hi} recommended"
|
||||
)
|
||||
|
||||
mech = block.get("business_mechanics") or []
|
||||
if not isinstance(mech, list):
|
||||
errors.append("`business_mechanics` must be a list")
|
||||
mech = []
|
||||
for m in mech:
|
||||
if m not in mechanics:
|
||||
errors.append(f"business_mechanics '{m}' not in {sorted(mechanics)}")
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
CLASSIFICATION_FILENAME = ".repo-classification.yaml"
|
||||
|
||||
# Market-domain slugs (Repo Classification Standard v1.0 §6).
|
||||
MARKET_DOMAIN_SLUGS: frozenset[str] = frozenset({
|
||||
"infotech",
|
||||
"financials",
|
||||
"communication",
|
||||
"consumer",
|
||||
"health",
|
||||
"industrials",
|
||||
"energy",
|
||||
"utilities",
|
||||
"materials",
|
||||
"realestate",
|
||||
"crypto",
|
||||
"agents",
|
||||
"space",
|
||||
"government",
|
||||
})
|
||||
|
||||
# Legacy coordination-domain slugs still found in workplan frontmatter ``domain:``.
|
||||
# Maps to market-domain slugs used by the Hub ``domains`` table post-migration.
|
||||
LEGACY_COORDINATION_TO_MARKET: dict[str, str] = {
|
||||
"custodian": "infotech",
|
||||
"railiance": "financials",
|
||||
"markitect": "communication",
|
||||
"coulomb_social": "communication",
|
||||
"personhood": "government",
|
||||
"foerster_capabilities": "agents",
|
||||
"capabilities": "agents",
|
||||
"canon": "infotech",
|
||||
"citation_evidence": "infotech",
|
||||
"helix_forge": "infotech",
|
||||
"inter_hub": "infotech",
|
||||
"netkingdom": "communication",
|
||||
"stack": "infotech",
|
||||
"vergabe_teilnahme": "government",
|
||||
"whynot": "consumer",
|
||||
"test_domain_v2": "infotech",
|
||||
}
|
||||
|
||||
|
||||
def resolve_topic_domain_slug(
|
||||
workplan_domain: str,
|
||||
*,
|
||||
repo_market_domain: str | None = None,
|
||||
) -> str:
|
||||
"""Map a workplan frontmatter ``domain`` value to a market-domain slug.
|
||||
|
||||
Workplans may still carry legacy coordination slugs (e.g. ``custodian``)
|
||||
after the spine migration; topic lookup must use the market domain stored
|
||||
on ``domains.slug``.
|
||||
"""
|
||||
domain = (workplan_domain or "").strip()
|
||||
if not domain:
|
||||
return repo_market_domain or ""
|
||||
if domain in MARKET_DOMAIN_SLUGS:
|
||||
return domain
|
||||
mapped = LEGACY_COORDINATION_TO_MARKET.get(domain)
|
||||
if mapped:
|
||||
return mapped
|
||||
return repo_market_domain or domain
|
||||
|
||||
|
||||
def load_classification_document(path: Path) -> dict | None:
|
||||
"""Load and return the YAML document, or ``None`` if missing/unreadable."""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
doc = yaml.safe_load(fh)
|
||||
except (OSError, yaml.YAMLError):
|
||||
return None
|
||||
return doc if isinstance(doc, dict) else None
|
||||
|
||||
|
||||
def extract_classification_block(doc: dict | None) -> dict | None:
|
||||
"""Return the inner ``repo_classification`` mapping from a loaded document."""
|
||||
if not doc:
|
||||
return None
|
||||
block = doc.get("repo_classification")
|
||||
return block if isinstance(block, dict) else None
|
||||
|
||||
|
||||
def load_classification_file(
|
||||
repo_path: Path | str,
|
||||
*,
|
||||
filename: str = CLASSIFICATION_FILENAME,
|
||||
) -> tuple[ClassificationData | None, list[str], list[str]]:
|
||||
"""Load ``.repo-classification.yaml`` from a repo root and validate it.
|
||||
|
||||
Returns ``(data, errors, warnings)``. *data* is ``None`` when the file is
|
||||
missing, unreadable, or has blocking validation errors.
|
||||
"""
|
||||
root = Path(repo_path)
|
||||
doc = load_classification_document(root / filename)
|
||||
block = extract_classification_block(doc)
|
||||
if block is None:
|
||||
if doc is None:
|
||||
return (None, [f"{filename} missing or unreadable"], [])
|
||||
return (None, [f"{filename} has no repo_classification block"], [])
|
||||
|
||||
errors, warnings = validate_classification(block)
|
||||
if errors:
|
||||
return (None, errors, warnings)
|
||||
return (ClassificationData.from_block(block), [], warnings)
|
||||
@@ -4,6 +4,8 @@ from api.models.domain_goal import DomainGoal, DomainGoalStatus
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.task import Task, TaskStatus, TaskPriority
|
||||
@@ -39,6 +41,8 @@ __all__ = [
|
||||
"Topic", "TopicStatus",
|
||||
"ManagedRepo",
|
||||
"RepoGoal", "RepoGoalStatus",
|
||||
"Workplan",
|
||||
"WorkplanDependency",
|
||||
"Workstream",
|
||||
"WorkstreamDependency",
|
||||
"Task", "TaskStatus", "TaskPriority",
|
||||
|
||||
@@ -31,9 +31,9 @@ class CapabilityRequest(Base, TimestampMixin):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
@@ -45,9 +45,9 @@ class CapabilityRequest(Base, TimestampMixin):
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
fulfilling_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
@@ -47,8 +47,8 @@ class Contribution(Base, TimestampMixin):
|
||||
related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
related_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
related_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
|
||||
@@ -62,5 +62,5 @@ class Contribution(Base, TimestampMixin):
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
|
||||
@@ -25,8 +25,8 @@ class Decision(Base, TimestampMixin):
|
||||
__tablename__ = "decisions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
|
||||
name="ck_decisions_topic_or_workstream",
|
||||
"topic_id IS NOT NULL OR workplan_id IS NOT NULL",
|
||||
name="ck_decisions_topic_or_workplan",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -36,8 +36,8 @@ class Decision(Base, TimestampMixin):
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -57,7 +57,7 @@ class Decision(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="decisions") # noqa: F821
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="decision", lazy="selectin"
|
||||
)
|
||||
|
||||
@@ -44,13 +44,13 @@ class ExtensionPoint(Base, TimestampMixin):
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
@@ -36,6 +36,15 @@ class ManagedRepo(Base, TimestampMixin):
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
category: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
secondary_domains: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
capability_tags: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
business_stake: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
business_mechanics: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
classified_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
classified_by: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
standard_version: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="repos", lazy="selectin"
|
||||
)
|
||||
|
||||
@@ -19,8 +19,8 @@ class ProgressEvent(Base):
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
@@ -38,6 +38,6 @@ class ProgressEvent(Base):
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="progress_events") # noqa: F821
|
||||
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
|
||||
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821
|
||||
|
||||
@@ -40,8 +40,8 @@ class RepoGoal(Base, TimestampMixin):
|
||||
domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
|
||||
"DomainGoal", back_populates="repo_goals", lazy="selectin"
|
||||
)
|
||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
||||
"Workstream", back_populates="repo_goal", lazy="selectin"
|
||||
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||
"Workplan", back_populates="repo_goal", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -30,8 +30,8 @@ class Task(Base, TimestampMixin):
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -50,7 +50,7 @@ class Task(Base, TimestampMixin):
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821
|
||||
subtasks: Mapped[list["Task"]] = relationship(
|
||||
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
||||
)
|
||||
|
||||
@@ -76,13 +76,13 @@ class TechnicalDebt(Base, TimestampMixin):
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
notes: Mapped[list["TDNote"]] = relationship(
|
||||
"TDNote", back_populates="td", lazy="selectin",
|
||||
order_by="TDNote.created_at",
|
||||
|
||||
@@ -27,8 +27,8 @@ class TokenEvent(Base):
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
@@ -75,5 +75,5 @@ class TokenEvent(Base):
|
||||
)
|
||||
|
||||
task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
|
||||
@@ -36,8 +36,8 @@ class Topic(Base, TimestampMixin):
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="topics", lazy="selectin"
|
||||
)
|
||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
||||
"Workstream", back_populates="topic", lazy="selectin"
|
||||
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||
"Workplan", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="topic", lazy="selectin"
|
||||
|
||||
70
api/models/workplan.py
Normal file
70
api/models/workplan.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class Workplan(Base, TimestampMixin):
|
||||
__tablename__ = "workplans"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
execution_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
||||
)
|
||||
launch_mode: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
||||
)
|
||||
concurrency_mode: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="sequential", server_default="sequential", index=True
|
||||
)
|
||||
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("repo_goals.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="workplans") # noqa: F821
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workplans", lazy="selectin") # noqa: F821
|
||||
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
|
||||
"Task", back_populates="workplan", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="workplan", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="workplan", lazy="selectin"
|
||||
)
|
||||
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
|
||||
"WorkplanLaunchRequest", back_populates="workplan", lazy="selectin"
|
||||
)
|
||||
75
api/models/workplan_dependency.py
Normal file
75
api/models/workplan_dependency.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class WorkplanDependency(Base, TimestampMixin):
|
||||
"""Directed dependency edge: `from_workplan` depends on a workplan or task.
|
||||
|
||||
Semantics: the target must reach a satisfactory state before `from_workplan`
|
||||
can fully proceed. Hard deletes are intentional —
|
||||
removing an edge removes a constraint, not information.
|
||||
"""
|
||||
|
||||
__tablename__ = "workplan_dependencies"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(to_workplan_id IS NOT NULL AND to_task_id IS NULL) "
|
||||
"OR (to_workplan_id IS NULL AND to_task_id IS NOT NULL)",
|
||||
name="ck_wp_dep_exactly_one_target",
|
||||
),
|
||||
Index(
|
||||
"uq_wp_dep_workplan_target",
|
||||
"from_workplan_id",
|
||||
"to_workplan_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_workplan_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"uq_wp_dep_task_target",
|
||||
"from_workplan_id",
|
||||
"to_task_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_task_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
from_workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
to_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
relationship_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False, default="blocks", server_default="blocks", index=True
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
from_workplan: Mapped["Workplan"] = relationship( # noqa: F821
|
||||
"Workplan", foreign_keys=[from_workplan_id]
|
||||
)
|
||||
to_workplan: Mapped["Workplan | None"] = relationship( # noqa: F821
|
||||
"Workplan", foreign_keys=[to_workplan_id]
|
||||
)
|
||||
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
|
||||
@@ -13,9 +13,9 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
||||
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
@@ -36,4 +36,4 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="launch_requests") # noqa: F821
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="launch_requests") # noqa: F821
|
||||
|
||||
@@ -1,70 +1,6 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
"""Backward-compatibility shim — prefer ``api.models.workplan``."""
|
||||
from api.models.workplan import Workplan
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
Workstream = Workplan
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class Workstream(Base, TimestampMixin):
|
||||
__tablename__ = "workstreams"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
execution_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
||||
)
|
||||
launch_mode: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
||||
)
|
||||
concurrency_mode: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="sequential", server_default="sequential", index=True
|
||||
)
|
||||
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("repo_goals.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workstreams", lazy="selectin") # noqa: F821
|
||||
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
|
||||
"Task", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
|
||||
"WorkplanLaunchRequest", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
__all__ = ["Workstream", "Workplan"]
|
||||
@@ -1,75 +1,6 @@
|
||||
import uuid
|
||||
"""Backward-compatibility shim — prefer ``api.models.workplan_dependency``."""
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
WorkstreamDependency = WorkplanDependency
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class WorkstreamDependency(Base, TimestampMixin):
|
||||
"""Directed dependency edge: `from_workstream` depends on a workstream or task.
|
||||
|
||||
Semantics: the target must reach a satisfactory state before `from_workstream`
|
||||
can fully proceed. Hard deletes are intentional —
|
||||
removing an edge removes a constraint, not information.
|
||||
"""
|
||||
|
||||
__tablename__ = "workstream_dependencies"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
|
||||
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
|
||||
name="ck_ws_dep_exactly_one_target",
|
||||
),
|
||||
Index(
|
||||
"uq_ws_dep_workstream_target",
|
||||
"from_workstream_id",
|
||||
"to_workstream_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_workstream_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"uq_ws_dep_task_target",
|
||||
"from_workstream_id",
|
||||
"to_task_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_task_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
from_workstream_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
to_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
relationship_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False, default="blocks", server_default="blocks", index=True
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
from_workstream: Mapped["Workstream"] = relationship( # noqa: F821
|
||||
"Workstream", foreign_keys=[from_workstream_id]
|
||||
)
|
||||
to_workstream: Mapped["Workstream | None"] = relationship( # noqa: F821
|
||||
"Workstream", foreign_keys=[to_workstream_id]
|
||||
)
|
||||
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
|
||||
__all__ = ["WorkstreamDependency", "WorkplanDependency"]
|
||||
@@ -68,7 +68,7 @@ async def create_request(
|
||||
priority=body.priority,
|
||||
requesting_domain_id=req_domain.id,
|
||||
requesting_agent=body.requesting_agent,
|
||||
requesting_workstream_id=body.requesting_workstream_id,
|
||||
requesting_workplan_id=body.requesting_workplan_id,
|
||||
blocking_task_id=body.blocking_task_id,
|
||||
fulfilling_domain_id=fulfilling_domain_id,
|
||||
catalog_entry_id=catalog_entry_id,
|
||||
@@ -115,7 +115,7 @@ async def accept_request(
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
req.status = "accepted"
|
||||
req.fulfilling_agent = body.fulfilling_agent
|
||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
||||
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||
req.accepted_at = now
|
||||
|
||||
# If no fulfilling domain was set by routing, infer from the accepting agent's context
|
||||
@@ -212,7 +212,7 @@ async def patch_request(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
|
||||
priority, blocking_task_id, fulfilling_workstream_id.
|
||||
priority, blocking_task_id, fulfilling_workplan_id.
|
||||
Only fields present in the request body (non-None) are updated.
|
||||
"""
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
@@ -241,9 +241,9 @@ async def patch_request(
|
||||
req.blocking_task_id = body.blocking_task_id
|
||||
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
|
||||
|
||||
if body.fulfilling_workstream_id is not None:
|
||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
||||
corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}")
|
||||
if body.fulfilling_workplan_id is not None:
|
||||
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
|
||||
|
||||
if not corrections:
|
||||
return req # no-op
|
||||
|
||||
@@ -43,7 +43,7 @@ async def create_contribution(
|
||||
title=body.title,
|
||||
body_path=body.body_path,
|
||||
related_topic_id=body.related_topic_id,
|
||||
related_workstream_id=body.related_workstream_id,
|
||||
related_workplan_id=body.related_workplan_id,
|
||||
notes=body.notes,
|
||||
status=ContributionStatus.draft,
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ def _needs_escalation(body: DecisionCreate) -> str | None:
|
||||
@router.get("/", response_model=list[DecisionRead])
|
||||
async def list_decisions(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: DecisionStatus | None = None,
|
||||
decision_type: DecisionType | None = None,
|
||||
@@ -48,8 +49,9 @@ async def list_decisions(
|
||||
q = select(Decision)
|
||||
if topic_id:
|
||||
q = q.where(Decision.topic_id == topic_id)
|
||||
if workstream_id:
|
||||
q = q.where(Decision.workstream_id == workstream_id)
|
||||
scope_id = workplan_id or workstream_id
|
||||
if scope_id:
|
||||
q = q.where(Decision.workplan_id == scope_id)
|
||||
if status:
|
||||
q = q.where(Decision.status == status)
|
||||
if decision_type:
|
||||
@@ -139,7 +141,7 @@ async def resolve_decision_action(
|
||||
|
||||
event = ProgressEvent(
|
||||
topic_id=decision.topic_id,
|
||||
workstream_id=decision.workstream_id,
|
||||
workplan_id=decision.workplan_id,
|
||||
decision_id=decision.id,
|
||||
event_type="decision_resolved",
|
||||
summary=f"Decision resolved: {decision.title}",
|
||||
@@ -159,7 +161,7 @@ async def resolve_decision_action(
|
||||
"decision_id": str(decision.id),
|
||||
"title": decision.title,
|
||||
"topic_id": str(decision.topic_id) if decision.topic_id else None,
|
||||
"workstream_id": str(decision.workstream_id) if decision.workstream_id else None,
|
||||
"workstream_id": str(decision.workplan_id) if decision.workplan_id else None,
|
||||
"decided_by": body.decided_by,
|
||||
"rationale_snippet": (body.rationale or "")[:240],
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ from api.models.extension_point import ExtensionPoint
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.domain import (
|
||||
DomainCreate,
|
||||
DomainDetail,
|
||||
@@ -32,9 +32,9 @@ async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainD
|
||||
workstream_count = 0
|
||||
if topic_ids:
|
||||
workstream_count_row = await session.execute(
|
||||
select(func.count()).select_from(Workstream)
|
||||
.where(Workstream.topic_id.in_(topic_ids))
|
||||
.where(Workstream.status == "active")
|
||||
select(func.count()).select_from(Workplan)
|
||||
.where(Workplan.topic_id.in_(topic_ids))
|
||||
.where(Workplan.status == "active")
|
||||
)
|
||||
workstream_count = workstream_count_row.scalar_one()
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from api.database import get_session
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.execution import (
|
||||
ExecutionIntentRead,
|
||||
ExecutionIntentUpdate,
|
||||
@@ -25,10 +25,10 @@ from api.services.execution_queue import (
|
||||
STATE_HUB_RESPONSIBILITIES,
|
||||
execution_state_for_launch,
|
||||
queue_sort_key,
|
||||
workstream_blockers,
|
||||
workplan_blockers,
|
||||
)
|
||||
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
||||
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
|
||||
|
||||
router = APIRouter(prefix="/execution", tags=["execution"])
|
||||
|
||||
@@ -50,7 +50,7 @@ async def _update_execution_intent(
|
||||
body: ExecutionIntentUpdate,
|
||||
session: AsyncSession,
|
||||
) -> ExecutionIntentRead:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
ws = await session.get(Workplan, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
|
||||
@@ -94,22 +94,22 @@ async def workplan_stack(
|
||||
include_blocked: bool = Query(True),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[WorkplanQueueItem]:
|
||||
result = await session.execute(select(Workstream))
|
||||
result = await session.execute(select(Workplan))
|
||||
workstreams = [
|
||||
ws for ws in result.scalars().all()
|
||||
if normalize_workstream_status(ws.status) not in CLOSED_WORKSTREAM_STATUSES
|
||||
if normalize_workplan_status(ws.status) not in CLOSED_WORKPLAN_STATUSES
|
||||
]
|
||||
ws_by_id = {ws.id: ws for ws in workstreams}
|
||||
ws_status = {ws.id: normalize_workstream_status(ws.status) for ws in workstreams}
|
||||
ws_status = {ws.id: normalize_workplan_status(ws.status) for ws in workstreams}
|
||||
|
||||
dep_result = await session.execute(select(WorkstreamDependency))
|
||||
dep_result = await session.execute(select(WorkplanDependency))
|
||||
ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||
for dep in dep_result.scalars().all():
|
||||
if dep.to_workstream_id is not None:
|
||||
ws_deps.setdefault(dep.from_workstream_id, []).append(dep.to_workstream_id)
|
||||
if dep.to_workplan_id is not None:
|
||||
ws_deps.setdefault(dep.from_workplan_id, []).append(dep.to_workplan_id)
|
||||
if dep.to_task_id is not None:
|
||||
task_deps.setdefault(dep.from_workstream_id, []).append(dep.to_task_id)
|
||||
task_deps.setdefault(dep.from_workplan_id, []).append(dep.to_task_id)
|
||||
|
||||
task_ids = [task_id for ids in task_deps.values() for task_id in ids]
|
||||
task_status: dict[uuid.UUID, str] = {}
|
||||
@@ -121,9 +121,9 @@ async def workplan_stack(
|
||||
for ws in workstreams:
|
||||
if not include_manual and ws.execution_state == "manual":
|
||||
continue
|
||||
lifecycle_status = normalize_workstream_status(ws.status)
|
||||
lifecycle_status = normalize_workplan_status(ws.status)
|
||||
blocked_ws = [
|
||||
blocker for blocker in workstream_blockers(ws.id, ws_deps, ws_status)
|
||||
blocker for blocker in workplan_blockers(ws.id, ws_deps, ws_status)
|
||||
if blocker in ws_by_id or blocker in ws_status
|
||||
]
|
||||
blocked_tasks = [
|
||||
@@ -135,7 +135,7 @@ async def workplan_stack(
|
||||
continue
|
||||
sort_key = queue_sort_key(ws, eligible=eligible)
|
||||
items.append(WorkplanQueueItem(
|
||||
workstream_id=ws.id,
|
||||
workplan_id=ws.id,
|
||||
slug=ws.slug,
|
||||
title=ws.title,
|
||||
status=lifecycle_status,
|
||||
@@ -149,7 +149,7 @@ async def workplan_stack(
|
||||
execution_group=ws.execution_group,
|
||||
scheduled_for=ws.scheduled_for,
|
||||
eligible=eligible,
|
||||
blocked_by_workstream_ids=blocked_ws,
|
||||
blocked_by_workplan_ids=blocked_ws,
|
||||
blocked_by_task_ids=blocked_tasks,
|
||||
sort_key=sort_key,
|
||||
))
|
||||
@@ -165,12 +165,12 @@ async def create_launch_request(
|
||||
body: LaunchRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkplanLaunchRequest:
|
||||
ws = await session.get(Workstream, body.workstream_id)
|
||||
ws = await session.get(Workplan, body.workplan_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
|
||||
launch_request = WorkplanLaunchRequest(
|
||||
workstream_id=ws.id,
|
||||
workplan_id=ws.id,
|
||||
requested_by=body.requested_by,
|
||||
requested_actor=body.requested_actor,
|
||||
launch_mode=body.launch_mode,
|
||||
@@ -199,16 +199,16 @@ async def list_launch_requests(
|
||||
) -> list[WorkplanLaunchRequest]:
|
||||
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
|
||||
if workstream_id:
|
||||
q = q.where(WorkplanLaunchRequest.workstream_id == workstream_id)
|
||||
q = q.where(WorkplanLaunchRequest.workplan_id == workstream_id)
|
||||
if request_status:
|
||||
q = q.where(WorkplanLaunchRequest.status == request_status)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def _intent_read(ws: Workstream) -> ExecutionIntentRead:
|
||||
def _intent_read(ws: Workplan) -> ExecutionIntentRead:
|
||||
return ExecutionIntentRead(
|
||||
workstream_id=ws.id,
|
||||
workplan_id=ws.id,
|
||||
execution_state=ws.execution_state,
|
||||
launch_mode=ws.launch_mode,
|
||||
concurrency_mode=ws.concurrency_mode,
|
||||
|
||||
@@ -17,10 +17,10 @@ from api.flow_defs import (
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.contribution import Contribution
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.services.lifecycle import transition_task_status, transition_workstream_status
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.services.lifecycle import transition_task_status, transition_workplan_status
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
router = APIRouter(prefix="/flows", tags=["flows"])
|
||||
|
||||
@@ -94,9 +94,9 @@ async def advance_workstation(
|
||||
|
||||
entity = await _entity(entity_type, entity_id, session)
|
||||
if entity_type == "workstream":
|
||||
transition_workstream_status(entity, target_workstation)
|
||||
transition_workplan_status(entity, target_workstation)
|
||||
elif entity_type == "task":
|
||||
parent = await session.get(Workstream, entity.workstream_id)
|
||||
parent = await session.get(Workplan, entity.workplan_id)
|
||||
transition_task_status(
|
||||
entity,
|
||||
target_workstation,
|
||||
@@ -117,7 +117,7 @@ async def _flow_object(
|
||||
) -> dict[str, Any]:
|
||||
entity = await _entity(entity_type, entity_id, session)
|
||||
status = _value(entity.status)
|
||||
current_status = normalize_workstream_status(status) if entity_type == "workstream" else status
|
||||
current_status = normalize_workplan_status(status) if entity_type == "workstream" else status
|
||||
obj: dict[str, Any] = {
|
||||
"id": str(entity.id),
|
||||
"status": current_status,
|
||||
@@ -127,21 +127,21 @@ async def _flow_object(
|
||||
|
||||
if entity_type == "workstream":
|
||||
tasks = list((await session.execute(
|
||||
select(Task).where(Task.workstream_id == entity_id)
|
||||
select(Task).where(Task.workplan_id == entity_id)
|
||||
)).scalars().all())
|
||||
deps = list((await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
WorkstreamDependency.from_workstream_id == entity_id
|
||||
select(WorkplanDependency).where(
|
||||
WorkplanDependency.from_workplan_id == entity_id
|
||||
)
|
||||
)).scalars().all())
|
||||
dependency_ids = [dep.to_workstream_id for dep in deps]
|
||||
dependency_ids = [dep.to_workplan_id for dep in deps]
|
||||
dependency_workstations: list[dict[str, Any]] = []
|
||||
if dependency_ids:
|
||||
dep_ws = list((await session.execute(
|
||||
select(Workstream).where(Workstream.id.in_(dependency_ids))
|
||||
select(Workplan).where(Workplan.id.in_(dependency_ids))
|
||||
)).scalars().all())
|
||||
dependency_workstations = [
|
||||
{"id": str(ws.id), "workstation": normalize_workstream_status(ws.status)}
|
||||
{"id": str(ws.id), "workstation": normalize_workplan_status(ws.status)}
|
||||
for ws in dep_ws
|
||||
]
|
||||
obj.update({
|
||||
@@ -163,7 +163,7 @@ async def _entity(
|
||||
session: AsyncSession,
|
||||
):
|
||||
model_by_type = {
|
||||
"workstream": Workstream,
|
||||
"workstream": Workplan,
|
||||
"task": Task,
|
||||
"contribution": Contribution,
|
||||
"capability_request": CapabilityRequest,
|
||||
|
||||
@@ -9,23 +9,23 @@ from api.models.agent_message import AgentMessage
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.models.task import TaskStatus
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
|
||||
from api.services.lifecycle import (
|
||||
should_activate_parent_for_task_start,
|
||||
status_value,
|
||||
transition_task_status,
|
||||
transition_workstream_status,
|
||||
transition_workplan_status,
|
||||
)
|
||||
from api.task_status import TERMINAL_TASK_STATUSES
|
||||
from api.services.reconciliation import (
|
||||
ReconciliationClass,
|
||||
StateChangeClassification,
|
||||
classify_task_status_change,
|
||||
classify_workstream_status_change,
|
||||
classify_workplan_status_change,
|
||||
)
|
||||
from api.services.workplan_files import (
|
||||
find_workplan_for_workstream,
|
||||
find_workplan_for_workplan,
|
||||
patch_task_status,
|
||||
patch_workplan_status,
|
||||
resolve_repo_path,
|
||||
@@ -33,7 +33,7 @@ from api.services.workplan_files import (
|
||||
task_block_linked,
|
||||
workplan_status,
|
||||
)
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
|
||||
|
||||
@@ -51,7 +51,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification:
|
||||
|
||||
|
||||
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
||||
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
|
||||
result = await session.execute(select(Task.status).where(Task.workplan_id == workstream_id))
|
||||
statuses = [status_value(row[0]) for row in result.all()]
|
||||
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
|
||||
|
||||
@@ -98,13 +98,13 @@ async def classify_state_change(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> StateChangeResponse:
|
||||
if body.target_type == "workstream":
|
||||
ws = await session.get(Workstream, body.target_id)
|
||||
ws = await session.get(Workplan, body.target_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
|
||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
|
||||
repo_path = resolve_repo_path(repo)
|
||||
workplan_ref = find_workplan_for_workstream(repo, ws.id) if repo_path else None
|
||||
workplan_ref = find_workplan_for_workplan(repo, ws.id) if repo_path else None
|
||||
actual_file_backed = workplan_ref is not None
|
||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||
file_backed = (
|
||||
@@ -122,9 +122,9 @@ async def classify_state_change(
|
||||
if body.tasks_terminal is not None
|
||||
else await _workstream_tasks_terminal(session, ws.id)
|
||||
)
|
||||
current_status = normalize_workstream_status(ws.status)
|
||||
target_status = normalize_workstream_status(body.target_status)
|
||||
classification = classify_workstream_status_change(
|
||||
current_status = normalize_workplan_status(ws.status)
|
||||
target_status = normalize_workplan_status(body.target_status)
|
||||
classification = classify_workplan_status_change(
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
file_backed=file_backed,
|
||||
@@ -136,7 +136,7 @@ async def classify_state_change(
|
||||
conflict = False
|
||||
if body.apply:
|
||||
expected_status = (
|
||||
normalize_workstream_status(body.expected_current_status)
|
||||
normalize_workplan_status(body.expected_current_status)
|
||||
if body.expected_current_status is not None
|
||||
else None
|
||||
)
|
||||
@@ -153,7 +153,7 @@ async def classify_state_change(
|
||||
)
|
||||
conflict = True
|
||||
elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref:
|
||||
file_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
||||
file_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||
if file_status and file_status != current_status:
|
||||
classification = _conflict(
|
||||
f"workplan file status {file_status!r} differs from cached DB status {current_status!r}",
|
||||
@@ -163,7 +163,7 @@ async def classify_state_change(
|
||||
else:
|
||||
try:
|
||||
patch_workplan_status(workplan_ref.path, target_status)
|
||||
patched_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
||||
patched_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||
except OSError as exc:
|
||||
classification = _conflict(
|
||||
f"workplan file write failed: {exc}",
|
||||
@@ -178,7 +178,7 @@ async def classify_state_change(
|
||||
)
|
||||
conflict = True
|
||||
else:
|
||||
transition_workstream_status(ws, target_status)
|
||||
transition_workplan_status(ws, target_status)
|
||||
await session.commit()
|
||||
write_result = "applied"
|
||||
|
||||
@@ -221,10 +221,10 @@ async def classify_state_change(
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
ws = await session.get(Workplan, task.workplan_id)
|
||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
|
||||
repo_path = resolve_repo_path(repo)
|
||||
workplan_ref = find_workplan_for_workstream(repo, ws.id) if ws and repo_path else None
|
||||
workplan_ref = find_workplan_for_workplan(repo, ws.id) if ws and repo_path else None
|
||||
actual_file_backed = workplan_ref is not None
|
||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||
file_backed = (
|
||||
@@ -291,7 +291,7 @@ async def classify_state_change(
|
||||
parent_will_activate = should_activate_parent_for_task_start(
|
||||
previous_task_status=current_status,
|
||||
new_task_status=target_status,
|
||||
parent_workstream_status=ws.status if ws else None,
|
||||
parent_workplan_status=ws.status if ws else None,
|
||||
)
|
||||
try:
|
||||
original_text = workplan_ref.path.read_text(encoding="utf-8")
|
||||
@@ -299,7 +299,7 @@ async def classify_state_change(
|
||||
patched_status = status_value(task_block_status(workplan_ref.path, task.id))
|
||||
if parent_will_activate:
|
||||
patch_workplan_status(workplan_ref.path, "active")
|
||||
parent_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
||||
parent_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||
if parent_status != "active":
|
||||
if original_text is not None:
|
||||
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
||||
|
||||
@@ -9,9 +9,10 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import case, func, select
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload
|
||||
|
||||
from api.config import settings
|
||||
from api.database import get_session
|
||||
@@ -29,11 +30,11 @@ from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal
|
||||
from api.models.tpsc import TPSCSnapshot
|
||||
from api.models.task import Task
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||
from api.schemas.managed_repo import (
|
||||
DispatchTask,
|
||||
DispatchWorkstream,
|
||||
DispatchWorkplan,
|
||||
PendingInterfaceChange,
|
||||
RepoCreate,
|
||||
RepoDispatch,
|
||||
@@ -44,6 +45,8 @@ from api.schemas.managed_repo import (
|
||||
RepoScopeHealth,
|
||||
RepoUpdate,
|
||||
ScopeIssueDetail,
|
||||
classification_fields_set,
|
||||
validate_repo_classification_fields,
|
||||
)
|
||||
from hub_core.routers.repos import create_repos_router
|
||||
|
||||
@@ -76,13 +79,107 @@ def _core_repo_router(**route_flags) -> APIRouter:
|
||||
repo_read_schema=RepoRead,
|
||||
repo_path_register_schema=RepoPathRegister,
|
||||
list_noload_fields=("goals",),
|
||||
create_extension_fields=("topic_id",),
|
||||
create_extension_fields=(
|
||||
"topic_id",
|
||||
"category",
|
||||
"secondary_domains",
|
||||
"capability_tags",
|
||||
"business_stake",
|
||||
"business_mechanics",
|
||||
"classified_at",
|
||||
"classified_by",
|
||||
"standard_version",
|
||||
),
|
||||
after_register=_publish_repo_registered,
|
||||
**route_flags,
|
||||
)
|
||||
|
||||
|
||||
router.include_router(_core_repo_router(include_slug_routes=False))
|
||||
router.include_router(
|
||||
_core_repo_router(include_collection_routes=False, include_slug_routes=False)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[RepoRead])
|
||||
async def list_repos(
|
||||
response: Response,
|
||||
domain: str | None = None,
|
||||
category: str | None = None,
|
||||
capability_tag: str | None = None,
|
||||
business_stake: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ManagedRepo]:
|
||||
"""List repos with optional domain and classification filters."""
|
||||
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||
q = (
|
||||
select(ManagedRepo)
|
||||
.options(noload(ManagedRepo.goals))
|
||||
.order_by(ManagedRepo.name)
|
||||
)
|
||||
if domain:
|
||||
domain_result = await session.execute(select(Domain).where(Domain.slug == domain))
|
||||
domain_obj = domain_result.scalar_one_or_none()
|
||||
if domain_obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
|
||||
q = q.where(
|
||||
or_(
|
||||
ManagedRepo.domain_id == domain_obj.id,
|
||||
ManagedRepo.secondary_domains.contains([domain]),
|
||||
)
|
||||
)
|
||||
if category:
|
||||
q = q.where(ManagedRepo.category == category)
|
||||
if capability_tag:
|
||||
q = q.where(ManagedRepo.capability_tags.contains([capability_tag]))
|
||||
if business_stake:
|
||||
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
|
||||
async def register_repo(
|
||||
body: RepoCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
|
||||
domain_obj = domain_result.scalar_one_or_none()
|
||||
if domain_obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
|
||||
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
|
||||
|
||||
payload = body.model_dump()
|
||||
validate_repo_classification_fields(
|
||||
domain_slug=body.domain_slug,
|
||||
fields=payload,
|
||||
require_complete=classification_fields_set(payload),
|
||||
)
|
||||
repo = ManagedRepo(
|
||||
domain_id=domain_obj.id,
|
||||
slug=body.slug,
|
||||
name=body.name,
|
||||
local_path=body.local_path,
|
||||
host_paths=body.host_paths,
|
||||
remote_url=body.remote_url,
|
||||
git_fingerprint=body.git_fingerprint,
|
||||
description=body.description,
|
||||
topic_id=body.topic_id,
|
||||
category=body.category,
|
||||
secondary_domains=body.secondary_domains,
|
||||
capability_tags=body.capability_tags,
|
||||
business_stake=body.business_stake,
|
||||
business_mechanics=body.business_mechanics,
|
||||
classified_at=body.classified_at,
|
||||
classified_by=body.classified_by,
|
||||
standard_version=body.standard_version,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
await _publish_repo_registered(repo, body, domain_obj)
|
||||
return repo
|
||||
|
||||
|
||||
@router.post("/onboard", response_model=RepoOnboardResult)
|
||||
@@ -428,6 +525,38 @@ async def list_repo_scope_health(
|
||||
return entries
|
||||
|
||||
|
||||
@router.patch("/{slug}", response_model=RepoRead)
|
||||
async def update_repo_with_classification(
|
||||
slug: str,
|
||||
body: RepoUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
"""Patch repo metadata including classification spine fields."""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
|
||||
domain_obj = domain_result.scalar_one_or_none()
|
||||
domain_slug = domain_obj.slug if domain_obj else ""
|
||||
if classification_fields_set(payload):
|
||||
merged = {
|
||||
"category": payload.get("category", repo.category),
|
||||
"secondary_domains": payload.get("secondary_domains", repo.secondary_domains),
|
||||
"capability_tags": payload.get("capability_tags", repo.capability_tags),
|
||||
"business_stake": payload.get("business_stake", repo.business_stake),
|
||||
"business_mechanics": payload.get("business_mechanics", repo.business_mechanics),
|
||||
}
|
||||
validate_repo_classification_fields(
|
||||
domain_slug=domain_slug,
|
||||
fields=merged,
|
||||
require_complete=True,
|
||||
)
|
||||
for field, value in payload.items():
|
||||
setattr(repo, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
router.include_router(
|
||||
_core_repo_router(
|
||||
include_collection_routes=False,
|
||||
@@ -480,19 +609,19 @@ async def get_repo_dispatch(
|
||||
|
||||
# Active workstreams
|
||||
ws_result = await session.execute(
|
||||
select(Workstream)
|
||||
.where(Workstream.repo_id == repo.id, Workstream.status == "active")
|
||||
.order_by(Workstream.created_at)
|
||||
select(Workplan)
|
||||
.where(Workplan.repo_id == repo.id, Workplan.status == "active")
|
||||
.order_by(Workplan.created_at)
|
||||
)
|
||||
workstreams = list(ws_result.scalars().all())
|
||||
|
||||
dispatch_workstreams: list[DispatchWorkstream] = []
|
||||
dispatch_workstreams: list[DispatchWorkplan] = []
|
||||
all_interventions: list[DispatchTask] = []
|
||||
|
||||
for ws in workstreams:
|
||||
task_result = await session.execute(
|
||||
select(Task)
|
||||
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "progress"]))
|
||||
.where(Task.workplan_id == ws.id, Task.status.in_(["todo", "progress"]))
|
||||
.order_by(Task.created_at)
|
||||
)
|
||||
tasks = list(task_result.scalars().all())
|
||||
@@ -511,7 +640,7 @@ async def get_repo_dispatch(
|
||||
all_interventions.extend(interventions)
|
||||
|
||||
dispatch_workstreams.append(
|
||||
DispatchWorkstream(
|
||||
DispatchWorkplan(
|
||||
id=ws.id,
|
||||
title=ws.title,
|
||||
status=ws.status,
|
||||
@@ -554,7 +683,7 @@ async def get_repo_dispatch(
|
||||
return RepoDispatch(
|
||||
repo_slug=slug,
|
||||
active_goal=active_goal,
|
||||
active_workstreams=dispatch_workstreams,
|
||||
active_workplans=dispatch_workstreams,
|
||||
human_interventions=all_interventions,
|
||||
pending_interface_changes=pending_changes,
|
||||
scope_needs_review=scope_needs_review,
|
||||
|
||||
@@ -21,8 +21,8 @@ from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.models.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.domain import DomainSummary
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
@@ -45,9 +45,9 @@ from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||
from api.routers.workstreams import _workplan_index
|
||||
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
||||
from api.workplan_status import (
|
||||
CLOSED_WORKSTREAM_STATUSES,
|
||||
OPEN_WORKSTREAM_STATUSES,
|
||||
normalize_workstream_status,
|
||||
CLOSED_WORKPLAN_STATUSES,
|
||||
OPEN_WORKPLAN_STATUSES,
|
||||
normalize_workplan_status,
|
||||
)
|
||||
from task_flow_engine import FlowEngine
|
||||
|
||||
@@ -82,7 +82,7 @@ async def get_summary(
|
||||
select(Topic)
|
||||
.options(
|
||||
selectinload(Topic.domain),
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.workplans),
|
||||
noload(Topic.decisions),
|
||||
noload(Topic.progress_events),
|
||||
)
|
||||
@@ -96,16 +96,16 @@ async def get_summary(
|
||||
if topic_ids:
|
||||
topic_ws_rows = await session.execute(
|
||||
select(
|
||||
Workstream.topic_id,
|
||||
Workstream.id,
|
||||
Workstream.slug,
|
||||
Workstream.title,
|
||||
Workstream.status,
|
||||
Workstream.owner,
|
||||
Workstream.due_date,
|
||||
Workplan.topic_id,
|
||||
Workplan.id,
|
||||
Workplan.slug,
|
||||
Workplan.title,
|
||||
Workplan.status,
|
||||
Workplan.owner,
|
||||
Workplan.due_date,
|
||||
)
|
||||
.where(Workstream.topic_id.in_(topic_ids))
|
||||
.order_by(Workstream.created_at)
|
||||
.where(Workplan.topic_id.in_(topic_ids))
|
||||
.order_by(Workplan.created_at)
|
||||
)
|
||||
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
|
||||
topic_workstreams.setdefault(topic_id, []).append({
|
||||
@@ -136,10 +136,10 @@ async def get_summary(
|
||||
recent = list(recent_rows.scalars().all())
|
||||
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
select(Workplan)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
|
||||
@@ -147,7 +147,7 @@ async def get_summary(
|
||||
task_per_ws: dict = {}
|
||||
task_statuses_per_ws: dict = {}
|
||||
for ws_id, tstat, cnt in await session.execute(
|
||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
||||
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||
):
|
||||
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
||||
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
|
||||
@@ -157,9 +157,9 @@ async def get_summary(
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
@@ -168,16 +168,16 @@ async def get_summary(
|
||||
dep_ws_ids = set()
|
||||
dep_task_ids = set()
|
||||
for d in dep_rows:
|
||||
dep_ws_ids.add(d.from_workstream_id)
|
||||
if d.to_workstream_id:
|
||||
dep_ws_ids.add(d.to_workstream_id)
|
||||
dep_ws_ids.add(d.from_workplan_id)
|
||||
if d.to_workplan_id:
|
||||
dep_ws_ids.add(d.to_workplan_id)
|
||||
if d.to_task_id:
|
||||
dep_task_ids.add(d.to_task_id)
|
||||
ws_lookup: dict = {w.id: w for w in open_ws}
|
||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||
if extra_ids:
|
||||
extra_rows = await session.execute(
|
||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
||||
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
|
||||
)
|
||||
for w in extra_rows.scalars():
|
||||
ws_lookup[w.id] = w
|
||||
@@ -189,7 +189,7 @@ async def get_summary(
|
||||
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||
for d in dep_rows:
|
||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
||||
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
|
||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id,
|
||||
@@ -230,9 +230,9 @@ async def get_summary(
|
||||
"workstation": w.status,
|
||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||
"dependencies": [
|
||||
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
||||
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
|
||||
for d in dep_rows
|
||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
||||
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
|
||||
],
|
||||
}
|
||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||
@@ -246,7 +246,7 @@ async def get_summary(
|
||||
select(Topic.status, func.count()).group_by(Topic.status)
|
||||
)}
|
||||
ws_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Workstream.status, func.count()).group_by(Workstream.status)
|
||||
select(Workplan.status, func.count()).group_by(Workplan.status)
|
||||
)}
|
||||
task_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Task.status, func.count()).group_by(Task.status)
|
||||
@@ -407,7 +407,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
select(Topic)
|
||||
.options(
|
||||
selectinload(Topic.domain),
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.workplans),
|
||||
noload(Topic.decisions),
|
||||
noload(Topic.progress_events),
|
||||
)
|
||||
@@ -418,12 +418,12 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
topic_map = {topic.id: topic for topic in topics}
|
||||
|
||||
workstream_rows = await session.execute(
|
||||
select(Workstream)
|
||||
select(Workplan)
|
||||
.options(noload("*"))
|
||||
.order_by(
|
||||
Workstream.planning_priority.asc().nullslast(),
|
||||
Workstream.planning_order.asc().nullslast(),
|
||||
Workstream.updated_at.desc(),
|
||||
Workplan.planning_priority.asc().nullslast(),
|
||||
Workplan.planning_order.asc().nullslast(),
|
||||
Workplan.updated_at.desc(),
|
||||
)
|
||||
)
|
||||
workstreams_all = list(workstream_rows.scalars().all())
|
||||
@@ -455,7 +455,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
task_statuses_per_ws: dict = {}
|
||||
task_totals_by_status: dict[str, int] = {}
|
||||
for ws_id, task_status, count in await session.execute(
|
||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
||||
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||
):
|
||||
status = status_value(task_status)
|
||||
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
|
||||
@@ -467,15 +467,15 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
|
||||
open_ws = [
|
||||
w for w in workstreams_all
|
||||
if normalize_workstream_status(w.status) in OPEN_WORKSTREAM_STATUSES
|
||||
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
|
||||
]
|
||||
open_ws_ids = [w.id for w in open_ws]
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
@@ -490,19 +490,19 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
"workstation": w.status,
|
||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||
"dependencies": [
|
||||
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
||||
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
|
||||
for d in dep_rows
|
||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
||||
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
|
||||
],
|
||||
}
|
||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workstream_status(w.status)
|
||||
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workplan_status(w.status)
|
||||
|
||||
topic_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Topic.status, func.count()).group_by(Topic.status)
|
||||
)}
|
||||
ws_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Workstream.status, func.count()).group_by(Workstream.status)
|
||||
select(Workplan.status, func.count()).group_by(Workplan.status)
|
||||
)}
|
||||
dec_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Decision.status, func.count()).group_by(Decision.status)
|
||||
@@ -631,7 +631,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
workplan_rows.append(DashboardWorkplanRow(
|
||||
id=w.id,
|
||||
title=w.title,
|
||||
status=normalize_workstream_status(w.status),
|
||||
status=normalize_workplan_status(w.status),
|
||||
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
|
||||
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
|
||||
workplan_filename=workplan.get("filename"),
|
||||
@@ -695,9 +695,9 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
||||
# Active workstream counts per domain (join through topics)
|
||||
ws_per_domain = {}
|
||||
for domain_id, cnt in await session.execute(
|
||||
select(Topic.domain_id, func.count(Workstream.id))
|
||||
.join(Workstream, Workstream.topic_id == Topic.id)
|
||||
.where(Workstream.status.in_(["active", "blocked"]))
|
||||
select(Topic.domain_id, func.count(Workplan.id))
|
||||
.join(Workplan, Workplan.topic_id == Topic.id)
|
||||
.where(Workplan.status.in_(["active", "blocked"]))
|
||||
.group_by(Topic.domain_id)
|
||||
):
|
||||
ws_per_domain[domain_id] = cnt
|
||||
@@ -734,10 +734,10 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
Used by workstreams.md and dependencies.md which only need dep edges.
|
||||
"""
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
select(Workplan)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
|
||||
@@ -745,9 +745,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
@@ -755,9 +755,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
dep_ws_ids: set = set()
|
||||
dep_task_ids: set = set()
|
||||
for d in dep_rows:
|
||||
dep_ws_ids.add(d.from_workstream_id)
|
||||
if d.to_workstream_id:
|
||||
dep_ws_ids.add(d.to_workstream_id)
|
||||
dep_ws_ids.add(d.from_workplan_id)
|
||||
if d.to_workplan_id:
|
||||
dep_ws_ids.add(d.to_workplan_id)
|
||||
if d.to_task_id:
|
||||
dep_task_ids.add(d.to_task_id)
|
||||
|
||||
@@ -765,7 +765,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||
if extra_ids:
|
||||
extra_rows = await session.execute(
|
||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
||||
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
|
||||
)
|
||||
for w in extra_rows.scalars():
|
||||
ws_lookup[w.id] = w
|
||||
@@ -777,7 +777,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
|
||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||
for d in dep_rows:
|
||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
||||
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
|
||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
|
||||
@@ -831,7 +831,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
.options(noload("*"))
|
||||
.where(Decision.status == DecisionStatus.resolved)
|
||||
.where(Decision.decided_at >= cutoff)
|
||||
.where(Decision.workstream_id.isnot(None))
|
||||
.where(Decision.workplan_id.isnot(None))
|
||||
.order_by(Decision.decided_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
@@ -839,7 +839,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
open_tasks_rows = await session.execute(
|
||||
select(Task)
|
||||
.options(noload("*"))
|
||||
.where(Task.workstream_id == decision.workstream_id)
|
||||
.where(Task.workplan_id == decision.workplan_id)
|
||||
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
|
||||
)
|
||||
open_tasks = list(open_tasks_rows.scalars().all())
|
||||
@@ -848,7 +848,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
||||
if task.id in seen_task_ids:
|
||||
continue
|
||||
ws = await session.get(Workstream, decision.workstream_id, options=[noload("*")])
|
||||
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
|
||||
domain_slug = await _get_domain_slug_for_workstream(ws, session)
|
||||
steps.append(NextStep(
|
||||
type="resolved_decision",
|
||||
@@ -868,13 +868,13 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
# ── Signal 2: cleared dependencies ──────────────────────────────────────
|
||||
all_dep_rows = await session.execute(
|
||||
select(
|
||||
WorkstreamDependency.from_workstream_id,
|
||||
WorkstreamDependency.to_workstream_id,
|
||||
).where(WorkstreamDependency.to_workstream_id.isnot(None))
|
||||
WorkplanDependency.from_workplan_id,
|
||||
WorkplanDependency.to_workplan_id,
|
||||
).where(WorkplanDependency.to_workplan_id.isnot(None))
|
||||
)
|
||||
all_deps = all_dep_rows.all()
|
||||
|
||||
# Group from_workstream_id → set of to_workstream_ids
|
||||
# Group from_workplan_id → set of to_workplan_ids
|
||||
dep_map: dict = {}
|
||||
dep_ws_ids = set()
|
||||
for from_ws_id, to_ws_id in all_deps:
|
||||
@@ -886,12 +886,12 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
if dep_ws_ids:
|
||||
ws_rows = await session.execute(
|
||||
select(
|
||||
Workstream.id,
|
||||
Workstream.status,
|
||||
Workstream.title,
|
||||
Workstream.slug,
|
||||
Workstream.topic_id,
|
||||
).where(Workstream.id.in_(dep_ws_ids))
|
||||
Workplan.id,
|
||||
Workplan.status,
|
||||
Workplan.title,
|
||||
Workplan.slug,
|
||||
Workplan.topic_id,
|
||||
).where(Workplan.id.in_(dep_ws_ids))
|
||||
)
|
||||
ws_info = {
|
||||
ws_id: {
|
||||
@@ -906,9 +906,9 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
ready_from_ws_ids = [
|
||||
from_ws_id
|
||||
for from_ws_id, to_ws_ids in dep_map.items()
|
||||
if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
|
||||
if normalize_workplan_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKPLAN_STATUSES
|
||||
and all(
|
||||
normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES
|
||||
normalize_workplan_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKPLAN_STATUSES
|
||||
for to_id in to_ws_ids
|
||||
)
|
||||
]
|
||||
@@ -918,11 +918,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
todo_rows = await session.execute(
|
||||
select(Task)
|
||||
.options(noload("*"))
|
||||
.where(Task.workstream_id.in_(ready_from_ws_ids))
|
||||
.where(Task.workplan_id.in_(ready_from_ws_ids))
|
||||
.where(Task.status == TaskStatus.todo)
|
||||
)
|
||||
for task in todo_rows.scalars().all():
|
||||
todo_by_ws.setdefault(task.workstream_id, []).append(task)
|
||||
todo_by_ws.setdefault(task.workplan_id, []).append(task)
|
||||
|
||||
for from_ws_id in ready_from_ws_ids:
|
||||
from_ws = ws_info.get(from_ws_id, {})
|
||||
@@ -956,7 +956,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
return steps
|
||||
|
||||
|
||||
async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None:
|
||||
async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None:
|
||||
"""Get the domain slug for a workstream via its topic."""
|
||||
if ws is None or ws.topic_id is None:
|
||||
return None
|
||||
|
||||
@@ -9,7 +9,7 @@ from api.database import get_session
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.task import (
|
||||
TaskCountRead,
|
||||
TaskCreate,
|
||||
@@ -26,6 +26,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
@router.get("/", response_model=list[TaskRead])
|
||||
async def list_tasks(
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
assignee: str | None = None,
|
||||
@@ -37,8 +38,9 @@ async def list_tasks(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Task]:
|
||||
q = select(Task)
|
||||
if workstream_id:
|
||||
q = q.where(Task.workstream_id == workstream_id)
|
||||
scope_id = workplan_id or workstream_id
|
||||
if scope_id:
|
||||
q = q.where(Task.workplan_id == scope_id)
|
||||
if status:
|
||||
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||
if assignee:
|
||||
@@ -60,18 +62,20 @@ async def list_tasks(
|
||||
|
||||
@router.get("/counts", response_model=list[TaskCountRead])
|
||||
async def count_tasks(
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[TaskCountRead]:
|
||||
q = select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
||||
if workstream_id:
|
||||
q = q.where(Task.workstream_id == workstream_id)
|
||||
q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||
scope_id = workplan_id or workstream_id
|
||||
if scope_id:
|
||||
q = q.where(Task.workplan_id == scope_id)
|
||||
if status:
|
||||
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||
rows = await session.execute(q)
|
||||
return [
|
||||
TaskCountRead(workstream_id=ws_id, status=task_status, count=count)
|
||||
TaskCountRead(workplan_id=ws_id, status=task_status, count=count)
|
||||
for ws_id, task_status, count in rows
|
||||
]
|
||||
|
||||
@@ -84,7 +88,7 @@ async def create_task(
|
||||
task = Task(**body.model_dump())
|
||||
session.add(task)
|
||||
if status_value(task.status) == "progress":
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
ws = await session.get(Workplan, task.workplan_id)
|
||||
transition_task_status(
|
||||
task,
|
||||
task.status,
|
||||
@@ -137,7 +141,7 @@ async def bulk_status_sync(
|
||||
target_status = status_value(update.status)
|
||||
if update.blocking_reason is not None:
|
||||
task.blocking_reason = update.blocking_reason
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
ws = await session.get(Workplan, task.workplan_id)
|
||||
transition_task_status(
|
||||
task,
|
||||
update.status,
|
||||
@@ -146,7 +150,7 @@ async def bulk_status_sync(
|
||||
)
|
||||
event = ProgressEvent(
|
||||
task_id=task.id,
|
||||
workstream_id=task.workstream_id,
|
||||
workplan_id=task.workplan_id,
|
||||
event_type="task_status_changed",
|
||||
summary=f"Task status -> {target_status}: {task.title}",
|
||||
author=author,
|
||||
@@ -218,7 +222,7 @@ async def update_task(
|
||||
for field, value in update_data.items():
|
||||
setattr(task, field, value)
|
||||
if new_status is not None:
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
ws = await session.get(Workplan, task.workplan_id)
|
||||
transition_task_status(
|
||||
task,
|
||||
status_update,
|
||||
@@ -247,7 +251,7 @@ async def update_task(
|
||||
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
|
||||
# Tier 2: prorate workplan total across task count
|
||||
count_result = await session.execute(
|
||||
select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id)
|
||||
select(func.count(Task.id)).where(Task.workplan_id == task.workplan_id)
|
||||
)
|
||||
task_count = max(count_result.scalar() or 1, 1)
|
||||
tin = token_data["workplan_tokens_in"] // task_count
|
||||
@@ -273,12 +277,12 @@ async def update_task(
|
||||
raw_metadata = {"estimation_method": "fixed_task_done_fallback"}
|
||||
|
||||
# Resolve repo_id via workstream
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
ws = await session.get(Workplan, task.workplan_id)
|
||||
repo_id = ws.repo_id if ws else None
|
||||
|
||||
event = TokenEvent(
|
||||
task_id=task_id,
|
||||
workstream_id=task.workstream_id,
|
||||
workplan_id=task.workplan_id,
|
||||
repo_id=repo_id,
|
||||
tokens_in=tin,
|
||||
tokens_out=tout,
|
||||
|
||||
@@ -11,7 +11,7 @@ from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.token_event import (
|
||||
RepoTokenSummary,
|
||||
TokenAggregateRow,
|
||||
@@ -102,14 +102,14 @@ def _apply_event_defaults(data: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
async def _populate_relationship_defaults(data: dict[str, Any], session: AsyncSession) -> dict[str, Any]:
|
||||
# Auto-populate workstream_id from task if not provided
|
||||
if data.get("task_id") and not data.get("workstream_id"):
|
||||
if data.get("task_id") and not data.get("workplan_id"):
|
||||
task = await session.get(Task, data["task_id"])
|
||||
if task:
|
||||
data["workstream_id"] = task.workstream_id
|
||||
data["workplan_id"] = task.workplan_id
|
||||
|
||||
# Auto-populate repo_id from workstream if not provided
|
||||
if data.get("workstream_id") and not data.get("repo_id"):
|
||||
ws = await session.get(Workstream, data["workstream_id"])
|
||||
if data.get("workplan_id") and not data.get("repo_id"):
|
||||
ws = await session.get(Workplan, data["workplan_id"])
|
||||
if ws and ws.repo_id:
|
||||
data["repo_id"] = ws.repo_id
|
||||
return data
|
||||
@@ -169,7 +169,7 @@ def _filter_query(
|
||||
if task_id:
|
||||
q = q.where(TokenEvent.task_id == task_id)
|
||||
if workstream_id:
|
||||
q = q.where(TokenEvent.workstream_id == workstream_id)
|
||||
q = q.where(TokenEvent.workplan_id == workstream_id)
|
||||
if repo_id:
|
||||
q = q.where(TokenEvent.repo_id == repo_id)
|
||||
if ref_type:
|
||||
@@ -195,7 +195,7 @@ def _filter_query(
|
||||
if unattributed:
|
||||
q = q.where(
|
||||
TokenEvent.repo_id.is_(None),
|
||||
TokenEvent.workstream_id.is_(None),
|
||||
TokenEvent.workplan_id.is_(None),
|
||||
TokenEvent.task_id.is_(None),
|
||||
)
|
||||
return q
|
||||
@@ -238,7 +238,7 @@ async def get_token_summary(
|
||||
uid = uuid.UUID(id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream")
|
||||
q = q.where(TokenEvent.workstream_id == uid)
|
||||
q = q.where(TokenEvent.workplan_id == uid)
|
||||
elif scope == "repo":
|
||||
try:
|
||||
uid = uuid.UUID(id)
|
||||
@@ -297,7 +297,7 @@ async def get_tokens_by_repo(
|
||||
Resolution order for each event:
|
||||
1. token_events.repo_id (direct)
|
||||
2. → workstreams.repo_id (via workstream_id)
|
||||
3. → task.workstream_id → workstreams.repo_id (via task_id)
|
||||
3. → task.workplan_id → workstreams.repo_id (via task_id)
|
||||
|
||||
Only events that resolve to a repo are included.
|
||||
"""
|
||||
@@ -314,8 +314,8 @@ async def get_tokens_by_repo(
|
||||
)
|
||||
events = list(events_result.scalars().all())
|
||||
|
||||
ws_result = await session.execute(select(Workstream))
|
||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
||||
ws_result = await session.execute(select(Workplan))
|
||||
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
|
||||
|
||||
task_result = await session.execute(select(Task))
|
||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
||||
@@ -326,9 +326,9 @@ async def get_tokens_by_repo(
|
||||
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
||||
if e.repo_id:
|
||||
return e.repo_id
|
||||
ws_id = e.workstream_id
|
||||
ws_id = e.workplan_id
|
||||
if not ws_id and e.task_id and e.task_id in task_map:
|
||||
ws_id = task_map[e.task_id].workstream_id
|
||||
ws_id = task_map[e.task_id].workplan_id
|
||||
if ws_id and ws_id in ws_map:
|
||||
return ws_map[ws_id].repo_id
|
||||
return None
|
||||
@@ -391,8 +391,8 @@ async def get_token_aggregate(
|
||||
)
|
||||
events = list(events_result.scalars().all())
|
||||
|
||||
ws_result = await session.execute(select(Workstream))
|
||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
||||
ws_result = await session.execute(select(Workplan))
|
||||
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
|
||||
|
||||
task_result = await session.execute(select(Task))
|
||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
||||
@@ -403,9 +403,9 @@ async def get_token_aggregate(
|
||||
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
||||
if e.repo_id:
|
||||
return e.repo_id
|
||||
ws_id = e.workstream_id
|
||||
ws_id = e.workplan_id
|
||||
if not ws_id and e.task_id and e.task_id in task_map:
|
||||
ws_id = task_map[e.task_id].workstream_id
|
||||
ws_id = task_map[e.task_id].workplan_id
|
||||
if ws_id and ws_id in ws_map:
|
||||
return ws_map[ws_id].repo_id
|
||||
return None
|
||||
@@ -458,7 +458,7 @@ async def get_token_aggregate(
|
||||
repo = repo_map.get(rid) if rid else None
|
||||
add(by_repo, str(rid) if rid else None, repo.slug if repo else None, e)
|
||||
|
||||
ws_id = e.workstream_id or (task_map[e.task_id].workstream_id if e.task_id in task_map else None)
|
||||
ws_id = e.workplan_id or (task_map[e.task_id].workplan_id if e.task_id in task_map else None)
|
||||
ws = ws_map.get(ws_id) if ws_id else None
|
||||
add(by_workstream, str(ws_id) if ws_id else None, ws.title if ws else None, e)
|
||||
|
||||
@@ -520,7 +520,7 @@ async def get_token_quality(
|
||||
source_counts[(e.measurement_kind, e.source_provider, e.source_id)] += 1
|
||||
if e.source_provider == "task_fallback" or e.note == "heuristic":
|
||||
fallback_count += 1
|
||||
if e.measurement_kind == "measured" and not (e.repo_id or e.workstream_id or e.task_id):
|
||||
if e.measurement_kind == "measured" and not (e.repo_id or e.workplan_id or e.task_id):
|
||||
unattributed_measured_count += 1
|
||||
if e.measurement_kind == "measured" and not e.source_id:
|
||||
missing_provenance_count += 1
|
||||
|
||||
@@ -30,7 +30,7 @@ async def list_topics(
|
||||
) -> list[Topic]:
|
||||
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||
q = select(Topic).options(
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.workplans),
|
||||
noload(Topic.decisions),
|
||||
noload(Topic.progress_events),
|
||||
)
|
||||
|
||||
@@ -6,9 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.task import Task
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead
|
||||
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
||||
|
||||
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
|
||||
@@ -17,28 +17,28 @@ workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"])
|
||||
|
||||
async def _create_dependency(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamDependencyCreate,
|
||||
workplan_id: uuid.UUID,
|
||||
body: WorkplanDependencyCreate,
|
||||
session: AsyncSession,
|
||||
) -> WorkstreamDependency:
|
||||
if await session.get(Workstream, workstream_id) is None:
|
||||
) -> WorkplanDependency:
|
||||
if await session.get(Workplan, workplan_id) is None:
|
||||
raise HTTPException(status_code=404, detail="from workplan not found")
|
||||
|
||||
has_workstream_target = body.to_workstream_id is not None
|
||||
has_workplan_target = body.to_workplan_id is not None
|
||||
has_task_target = body.to_task_id is not None
|
||||
if has_workstream_target == has_task_target:
|
||||
if has_workplan_target == has_task_target:
|
||||
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
|
||||
|
||||
if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None:
|
||||
if body.to_workplan_id and await session.get(Workplan, body.to_workplan_id) is None:
|
||||
raise HTTPException(status_code=404, detail="target workplan not found")
|
||||
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
|
||||
raise HTTPException(status_code=404, detail="target task not found")
|
||||
if workstream_id == body.to_workstream_id:
|
||||
if workplan_id == body.to_workplan_id:
|
||||
raise HTTPException(status_code=422, detail="a workplan cannot depend on itself")
|
||||
|
||||
dep = WorkstreamDependency(
|
||||
from_workstream_id=workstream_id,
|
||||
to_workstream_id=body.to_workstream_id,
|
||||
dep = WorkplanDependency(
|
||||
from_workplan_id=workplan_id,
|
||||
to_workplan_id=body.to_workplan_id,
|
||||
to_task_id=body.to_task_id,
|
||||
relationship_type=body.relationship_type,
|
||||
description=body.description,
|
||||
@@ -51,15 +51,15 @@ async def _create_dependency(
|
||||
|
||||
async def _list_dependencies(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> list[WorkstreamDependency]:
|
||||
if await session.get(Workstream, workstream_id) is None:
|
||||
) -> list[WorkplanDependency]:
|
||||
if await session.get(Workplan, workplan_id) is None:
|
||||
raise HTTPException(status_code=404, detail="workplan not found")
|
||||
rows = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id == workstream_id)
|
||||
| (WorkstreamDependency.to_workstream_id == workstream_id)
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id == workplan_id)
|
||||
| (WorkplanDependency.to_workplan_id == workplan_id)
|
||||
)
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
@@ -67,14 +67,14 @@ async def _list_dependencies(
|
||||
|
||||
async def _delete_dependency(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
workplan_id: uuid.UUID,
|
||||
dep_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
dep = await session.get(WorkstreamDependency, dep_id)
|
||||
dep = await session.get(WorkplanDependency, dep_id)
|
||||
if dep is None:
|
||||
raise HTTPException(status_code=404, detail="dependency not found")
|
||||
if dep.from_workstream_id != workstream_id:
|
||||
if dep.from_workplan_id != workplan_id:
|
||||
raise HTTPException(status_code=403, detail="dependency does not belong to this workplan")
|
||||
await session.delete(dep)
|
||||
await session.commit()
|
||||
@@ -82,17 +82,17 @@ async def _delete_dependency(
|
||||
|
||||
@router.post(
|
||||
"/{workstream_id}/dependencies/",
|
||||
response_model=WorkstreamDependencyRead,
|
||||
response_model=WorkplanDependencyRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_dependency(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamDependencyCreate,
|
||||
body: WorkplanDependencyCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkstreamDependency:
|
||||
"""Record that workstream_id depends on another workstream or a task."""
|
||||
) -> WorkplanDependency:
|
||||
"""Record that workstream_id depends on another workplan or a task."""
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -100,33 +100,33 @@ async def create_dependency(
|
||||
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
|
||||
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
||||
)
|
||||
return await _create_dependency(workstream_id=workstream_id, body=body, session=session)
|
||||
return await _create_dependency(workplan_id=workstream_id, body=body, session=session)
|
||||
|
||||
|
||||
@workplan_router.post(
|
||||
"/{workplan_id}/dependencies/",
|
||||
response_model=WorkstreamDependencyRead,
|
||||
response_model=WorkplanDependencyRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_workplan_dependency(
|
||||
workplan_id: uuid.UUID,
|
||||
body: WorkstreamDependencyCreate,
|
||||
body: WorkplanDependencyCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkstreamDependency:
|
||||
return await _create_dependency(workstream_id=workplan_id, body=body, session=session)
|
||||
) -> WorkplanDependency:
|
||||
return await _create_dependency(workplan_id=workplan_id, body=body, session=session)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{workstream_id}/dependencies/",
|
||||
response_model=list[WorkstreamDependencyRead],
|
||||
response_model=list[WorkplanDependencyRead],
|
||||
)
|
||||
async def list_dependencies(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[WorkstreamDependency]:
|
||||
"""Return all dependency edges touching this workstream (both directions)."""
|
||||
) -> list[WorkplanDependency]:
|
||||
"""Return all dependency edges touching this workplan (both directions)."""
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -134,18 +134,18 @@ async def list_dependencies(
|
||||
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
|
||||
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
||||
)
|
||||
return await _list_dependencies(workstream_id=workstream_id, session=session)
|
||||
return await _list_dependencies(workplan_id=workstream_id, session=session)
|
||||
|
||||
|
||||
@workplan_router.get(
|
||||
"/{workplan_id}/dependencies/",
|
||||
response_model=list[WorkstreamDependencyRead],
|
||||
response_model=list[WorkplanDependencyRead],
|
||||
)
|
||||
async def list_workplan_dependencies(
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[WorkstreamDependency]:
|
||||
return await _list_dependencies(workstream_id=workplan_id, session=session)
|
||||
) -> list[WorkplanDependency]:
|
||||
return await _list_dependencies(workplan_id=workplan_id, session=session)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -167,7 +167,7 @@ async def delete_dependency(
|
||||
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
|
||||
replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}",
|
||||
)
|
||||
await _delete_dependency(workstream_id=workstream_id, dep_id=dep_id, session=session)
|
||||
await _delete_dependency(workplan_id=workstream_id, dep_id=dep_id, session=session)
|
||||
|
||||
|
||||
@workplan_router.delete(
|
||||
@@ -179,4 +179,4 @@ async def delete_workplan_dependency(
|
||||
dep_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
await _delete_dependency(workstream_id=workplan_id, dep_id=dep_id, session=session)
|
||||
await _delete_dependency(workplan_id=workplan_id, dep_id=dep_id, session=session)
|
||||
@@ -15,21 +15,21 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from api.database import get_session
|
||||
from api.events import EventEnvelope, publish_event
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.workstream import (
|
||||
WorkstreamCreate,
|
||||
WorkstreamRead,
|
||||
WorkstreamUpdate,
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.workplan import (
|
||||
WorkplanCreate,
|
||||
WorkplanRead,
|
||||
WorkplanUpdate,
|
||||
)
|
||||
from api.services.lifecycle import transition_workstream_status
|
||||
from api.services.lifecycle import transition_workplan_status
|
||||
from api.services.legacy_meter import (
|
||||
LegacyUsageIdentity,
|
||||
identity_from_request,
|
||||
record_legacy_usage,
|
||||
)
|
||||
from api.workplan_status import (
|
||||
is_supported_workstream_status,
|
||||
normalize_workstream_status,
|
||||
is_supported_workplan_status,
|
||||
normalize_workplan_status,
|
||||
ready_review_status,
|
||||
)
|
||||
|
||||
@@ -138,7 +138,7 @@ async def _meter_legacy_event(
|
||||
logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True)
|
||||
|
||||
|
||||
async def _list_workstreams(
|
||||
async def _list_workplans(
|
||||
*,
|
||||
topic_id: uuid.UUID | None,
|
||||
repo_id: uuid.UUID | None,
|
||||
@@ -147,27 +147,27 @@ async def _list_workstreams(
|
||||
owner: str | None,
|
||||
slug: str | None,
|
||||
session: AsyncSession,
|
||||
) -> list[Workstream]:
|
||||
q = select(Workstream)
|
||||
) -> list[Workplan]:
|
||||
q = select(Workplan)
|
||||
if topic_id:
|
||||
q = q.where(Workstream.topic_id == topic_id)
|
||||
q = q.where(Workplan.topic_id == topic_id)
|
||||
if repo_id:
|
||||
q = q.where(Workstream.repo_id == repo_id)
|
||||
q = q.where(Workplan.repo_id == repo_id)
|
||||
if repo_goal_id:
|
||||
q = q.where(Workstream.repo_goal_id == repo_goal_id)
|
||||
q = q.where(Workplan.repo_goal_id == repo_goal_id)
|
||||
if status_filter:
|
||||
normalised_status = normalize_workstream_status(status_filter)
|
||||
if not is_supported_workstream_status(status_filter):
|
||||
normalised_status = normalize_workplan_status(status_filter)
|
||||
if not is_supported_workplan_status(status_filter):
|
||||
raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'")
|
||||
q = q.where(Workstream.status == normalised_status)
|
||||
q = q.where(Workplan.status == normalised_status)
|
||||
if owner:
|
||||
q = q.where(Workstream.owner == owner)
|
||||
q = q.where(Workplan.owner == owner)
|
||||
if slug:
|
||||
q = q.where(Workstream.slug == slug)
|
||||
q = q.where(Workplan.slug == slug)
|
||||
q = q.order_by(
|
||||
Workstream.planning_priority.asc().nullslast(),
|
||||
Workstream.planning_order.asc().nullslast(),
|
||||
Workstream.updated_at.desc(),
|
||||
Workplan.planning_priority.asc().nullslast(),
|
||||
Workplan.planning_order.asc().nullslast(),
|
||||
Workplan.updated_at.desc(),
|
||||
)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
@@ -190,10 +190,10 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
|
||||
continue
|
||||
for path in sorted(directory.glob("*.md")):
|
||||
data = _frontmatter(path)
|
||||
workstream_id = data.get("state_hub_workstream_id")
|
||||
if not workstream_id:
|
||||
workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id")
|
||||
if not workplan_id:
|
||||
continue
|
||||
file_status = normalize_workstream_status(data.get("status", ""))
|
||||
file_status = normalize_workplan_status(data.get("status", ""))
|
||||
review = (
|
||||
ready_review_status(
|
||||
root,
|
||||
@@ -203,7 +203,7 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
|
||||
if file_status == "ready"
|
||||
else None
|
||||
)
|
||||
index[str(workstream_id)] = {
|
||||
index[str(workplan_id)] = {
|
||||
"filename": path.name,
|
||||
"relative_path": str(path.relative_to(root)),
|
||||
"repo_slug": repo.slug,
|
||||
@@ -287,79 +287,79 @@ async def _workplan_index(
|
||||
return _INDEX_CACHE
|
||||
|
||||
|
||||
async def _create_workstream(
|
||||
async def _create_workplan(
|
||||
*,
|
||||
body: WorkstreamCreate,
|
||||
body: WorkplanCreate,
|
||||
session: AsyncSession,
|
||||
) -> Workstream:
|
||||
ws = Workstream(**body.model_dump())
|
||||
session.add(ws)
|
||||
) -> Workplan:
|
||||
wp = Workplan(**body.model_dump())
|
||||
session.add(wp)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
await session.refresh(wp)
|
||||
return wp
|
||||
|
||||
|
||||
async def _get_workstream(
|
||||
async def _get_workplan(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
) -> Workplan:
|
||||
wp = await session.get(Workplan, workplan_id)
|
||||
if wp is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
return ws
|
||||
return wp
|
||||
|
||||
|
||||
async def _update_workstream(
|
||||
async def _update_workplan(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamUpdate,
|
||||
workplan_id: uuid.UUID,
|
||||
body: WorkplanUpdate,
|
||||
session: AsyncSession,
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
) -> Workplan:
|
||||
wp = await session.get(Workplan, workplan_id)
|
||||
if wp is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
status_update = update_data.pop("status", None)
|
||||
prev_status = ws.status
|
||||
prev_status = wp.status
|
||||
for field, value in update_data.items():
|
||||
setattr(ws, field, value)
|
||||
setattr(wp, field, value)
|
||||
if status_update is not None:
|
||||
transition_workstream_status(ws, status_update)
|
||||
transition_workplan_status(wp, status_update)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
await session.refresh(wp)
|
||||
|
||||
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
|
||||
await _publish_completion_events(ws, session)
|
||||
if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished":
|
||||
await _publish_completion_events(wp, session)
|
||||
|
||||
return ws
|
||||
return wp
|
||||
|
||||
|
||||
async def _archive_workstream(
|
||||
async def _archive_workplan(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
) -> Workplan:
|
||||
wp = await session.get(Workplan, workplan_id)
|
||||
if wp is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
transition_workstream_status(ws, "archived")
|
||||
transition_workplan_status(wp, "archived")
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
await session.refresh(wp)
|
||||
return wp
|
||||
|
||||
|
||||
async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> None:
|
||||
async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> None:
|
||||
workplan_envelope = EventEnvelope.new(
|
||||
_COMPLETED_WORKPLAN_EVENT,
|
||||
attributes={
|
||||
"workplan_id": str(ws.id),
|
||||
"legacy_workstream_id": str(ws.id),
|
||||
"slug": ws.slug,
|
||||
"title": ws.title,
|
||||
"topic_id": str(ws.topic_id),
|
||||
"repo_id": str(ws.repo_id) if ws.repo_id else None,
|
||||
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
|
||||
"workplan_id": str(wp.id),
|
||||
"legacy_workstream_id": str(wp.id),
|
||||
"slug": wp.slug,
|
||||
"title": wp.title,
|
||||
"topic_id": str(wp.topic_id) if wp.topic_id else None,
|
||||
"repo_id": str(wp.repo_id) if wp.repo_id else None,
|
||||
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope))
|
||||
@@ -372,18 +372,18 @@ async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> N
|
||||
legacy_envelope = EventEnvelope.new(
|
||||
_COMPLETED_WORKSTREAM_EVENT,
|
||||
attributes={
|
||||
"workstream_id": str(ws.id),
|
||||
"slug": ws.slug,
|
||||
"title": ws.title,
|
||||
"topic_id": str(ws.topic_id),
|
||||
"repo_id": str(ws.repo_id) if ws.repo_id else None,
|
||||
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
|
||||
"workstream_id": str(wp.id),
|
||||
"slug": wp.slug,
|
||||
"title": wp.title,
|
||||
"topic_id": str(wp.topic_id) if wp.topic_id else None,
|
||||
"repo_id": str(wp.repo_id) if wp.repo_id else None,
|
||||
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope))
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkstreamRead])
|
||||
@router.get("/", response_model=list[WorkplanRead])
|
||||
async def list_workstreams(
|
||||
request: Request,
|
||||
response: Response,
|
||||
@@ -394,7 +394,7 @@ async def list_workstreams(
|
||||
owner: str | None = None,
|
||||
slug: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Workstream]:
|
||||
) -> list[Workplan]:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -402,7 +402,7 @@ async def list_workstreams(
|
||||
interface_key=_legacy_key("GET", "/workstreams/"),
|
||||
replacement_ref="/workplans/",
|
||||
)
|
||||
return await _list_workstreams(
|
||||
return await _list_workplans(
|
||||
topic_id=topic_id,
|
||||
repo_id=repo_id,
|
||||
repo_goal_id=repo_goal_id,
|
||||
@@ -413,7 +413,7 @@ async def list_workstreams(
|
||||
)
|
||||
|
||||
|
||||
@workplan_router.get("/", response_model=list[WorkstreamRead])
|
||||
@workplan_router.get("/", response_model=list[WorkplanRead])
|
||||
async def list_workplans(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
repo_id: uuid.UUID | None = None,
|
||||
@@ -422,8 +422,8 @@ async def list_workplans(
|
||||
owner: str | None = None,
|
||||
slug: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Workstream]:
|
||||
return await _list_workstreams(
|
||||
) -> list[Workplan]:
|
||||
return await _list_workplans(
|
||||
topic_id=topic_id,
|
||||
repo_id=repo_id,
|
||||
repo_goal_id=repo_goal_id,
|
||||
@@ -459,13 +459,13 @@ async def workplan_index_preferred(
|
||||
return await _workplan_index(refresh=refresh, session=session)
|
||||
|
||||
|
||||
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workstream(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: WorkstreamCreate,
|
||||
body: WorkplanCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
) -> Workplan:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -473,24 +473,24 @@ async def create_workstream(
|
||||
interface_key=_legacy_key("POST", "/workstreams/"),
|
||||
replacement_ref="/workplans/",
|
||||
)
|
||||
return await _create_workstream(body=body, session=session)
|
||||
return await _create_workplan(body=body, session=session)
|
||||
|
||||
|
||||
@workplan_router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
||||
@workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workplan(
|
||||
body: WorkstreamCreate,
|
||||
body: WorkplanCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
return await _create_workstream(body=body, session=session)
|
||||
) -> Workplan:
|
||||
return await _create_workplan(body=body, session=session)
|
||||
|
||||
|
||||
@router.get("/{workstream_id}", response_model=WorkstreamRead)
|
||||
@router.get("/{workstream_id}", response_model=WorkplanRead)
|
||||
async def get_workstream(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
) -> Workplan:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -498,25 +498,25 @@ async def get_workstream(
|
||||
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
|
||||
replacement_ref="/workplans/{workplan_id}",
|
||||
)
|
||||
return await _get_workstream(workstream_id=workstream_id, session=session)
|
||||
return await _get_workplan(workplan_id=workstream_id, session=session)
|
||||
|
||||
|
||||
@workplan_router.get("/{workplan_id}", response_model=WorkstreamRead)
|
||||
@workplan_router.get("/{workplan_id}", response_model=WorkplanRead)
|
||||
async def get_workplan(
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
return await _get_workstream(workstream_id=workplan_id, session=session)
|
||||
) -> Workplan:
|
||||
return await _get_workplan(workplan_id=workplan_id, session=session)
|
||||
|
||||
|
||||
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
|
||||
@router.patch("/{workstream_id}", response_model=WorkplanRead)
|
||||
async def update_workstream(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamUpdate,
|
||||
body: WorkplanUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
) -> Workplan:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -524,25 +524,25 @@ async def update_workstream(
|
||||
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
|
||||
replacement_ref="/workplans/{workplan_id}",
|
||||
)
|
||||
return await _update_workstream(workstream_id=workstream_id, body=body, session=session)
|
||||
return await _update_workplan(workplan_id=workstream_id, body=body, session=session)
|
||||
|
||||
|
||||
@workplan_router.patch("/{workplan_id}", response_model=WorkstreamRead)
|
||||
@workplan_router.patch("/{workplan_id}", response_model=WorkplanRead)
|
||||
async def update_workplan(
|
||||
workplan_id: uuid.UUID,
|
||||
body: WorkstreamUpdate,
|
||||
body: WorkplanUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
return await _update_workstream(workstream_id=workplan_id, body=body, session=session)
|
||||
) -> Workplan:
|
||||
return await _update_workplan(workplan_id=workplan_id, body=body, session=session)
|
||||
|
||||
|
||||
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
|
||||
@router.delete("/{workstream_id}", response_model=WorkplanRead)
|
||||
async def archive_workstream(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
) -> Workplan:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -550,12 +550,12 @@ async def archive_workstream(
|
||||
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
|
||||
replacement_ref="/workplans/{workplan_id}",
|
||||
)
|
||||
return await _archive_workstream(workstream_id=workstream_id, session=session)
|
||||
return await _archive_workplan(workplan_id=workstream_id, session=session)
|
||||
|
||||
|
||||
@workplan_router.delete("/{workplan_id}", response_model=WorkstreamRead)
|
||||
@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead)
|
||||
async def archive_workplan(
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
return await _archive_workstream(workstream_id=workplan_id, session=session)
|
||||
) -> Workplan:
|
||||
return await _archive_workplan(workplan_id=workplan_id, session=session)
|
||||
@@ -1,4 +1,5 @@
|
||||
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
|
||||
from api.schemas.workplan import WorkplanCreate, WorkplanUpdate, WorkplanRead
|
||||
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
|
||||
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
||||
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
||||
@@ -9,6 +10,7 @@ from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead
|
||||
|
||||
__all__ = [
|
||||
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
||||
"WorkplanCreate", "WorkplanUpdate", "WorkplanRead",
|
||||
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
|
||||
"TaskCreate", "TaskUpdate", "TaskRead",
|
||||
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
from hub_core.schemas.capability import (
|
||||
CapabilityRequestDispute,
|
||||
@@ -23,20 +23,29 @@ class CapabilityRequestCreate(BaseModel):
|
||||
priority: str = "medium"
|
||||
requesting_domain: str # slug, resolved to domain_id in router
|
||||
requesting_agent: str
|
||||
requesting_workstream_id: uuid.UUID | None = None
|
||||
requesting_workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("requesting_workplan_id", "requesting_workstream_id"),
|
||||
)
|
||||
blocking_task_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class CapabilityRequestAccept(BaseModel):
|
||||
fulfilling_agent: str
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
fulfilling_workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
|
||||
)
|
||||
|
||||
|
||||
class CapabilityRequestPatch(BaseModel):
|
||||
catalog_entry_id: uuid.UUID | None = None
|
||||
priority: str | None = None
|
||||
blocking_task_id: uuid.UUID | None = None
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
fulfilling_workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
|
||||
)
|
||||
|
||||
|
||||
class CapabilityRequestReroute(BaseModel):
|
||||
@@ -57,10 +66,10 @@ class CapabilityRequestRead(BaseModel):
|
||||
status: str
|
||||
requesting_domain_slug: str
|
||||
requesting_agent: str
|
||||
requesting_workstream_id: uuid.UUID | None = None
|
||||
requesting_workplan_id: uuid.UUID | None = None
|
||||
fulfilling_domain_slug: str | None = None
|
||||
fulfilling_agent: str | None = None
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
fulfilling_workplan_id: uuid.UUID | None = None
|
||||
blocking_task_id: uuid.UUID | None = None
|
||||
catalog_entry_id: uuid.UUID | None = None
|
||||
resolution_note: str | None = None
|
||||
@@ -73,3 +82,13 @@ class CapabilityRequestRead(BaseModel):
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def requesting_workstream_id(self) -> uuid.UUID | None:
|
||||
return self.requesting_workplan_id
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def fulfilling_workstream_id(self) -> uuid.UUID | None:
|
||||
return self.fulfilling_workplan_id
|
||||
|
||||
43
api/schemas/compat.py
Normal file
43
api/schemas/compat.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Shared Pydantic field helpers for workplan / workstream compatibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import AliasChoices, Field, computed_field, model_validator
|
||||
|
||||
|
||||
def workplan_id_field(*, default: uuid.UUID | None = None) -> uuid.UUID | None:
|
||||
return Field(
|
||||
default=default,
|
||||
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||
)
|
||||
|
||||
|
||||
class WorkplanIdCompatMixin:
|
||||
"""Accept ``workplan_id`` or legacy ``workstream_id`` on input; emit both on output."""
|
||||
|
||||
workplan_id: uuid.UUID = workplan_id_field()
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID:
|
||||
return self.workplan_id
|
||||
|
||||
|
||||
class WorkplanIdCreateMixin:
|
||||
workplan_id: uuid.UUID | None = workplan_id_field(default=None)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_workplan_id(self):
|
||||
if self.workplan_id is None:
|
||||
raise ValueError("workplan_id is required")
|
||||
return self
|
||||
|
||||
|
||||
class OptionalWorkplanIdCompatMixin:
|
||||
workplan_id: uuid.UUID | None = workplan_id_field(default=None)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID | None:
|
||||
return self.workplan_id
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
from api.models.contribution import ContributionStatus, ContributionType
|
||||
|
||||
@@ -14,7 +14,10 @@ class ContributionCreate(BaseModel):
|
||||
title: str
|
||||
body_path: str | None = None
|
||||
related_topic_id: uuid.UUID | None = None
|
||||
related_workstream_id: uuid.UUID | None = None
|
||||
related_workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("related_workplan_id", "related_workstream_id"),
|
||||
)
|
||||
repo_id: uuid.UUID | None = None
|
||||
notes: str | None = None
|
||||
|
||||
@@ -36,10 +39,15 @@ class ContributionRead(BaseModel):
|
||||
status: ContributionStatus
|
||||
body_path: str | None = None
|
||||
related_topic_id: uuid.UUID | None = None
|
||||
related_workstream_id: uuid.UUID | None = None
|
||||
related_workplan_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
submitted_at: datetime | None = None
|
||||
resolved_at: datetime | None = None
|
||||
notes: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def related_workstream_id(self) -> uuid.UUID | None:
|
||||
return self.related_workplan_id
|
||||
|
||||
@@ -4,11 +4,16 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from api.models.decision import DecisionStatus, DecisionType
|
||||
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
|
||||
class DecisionCreate(BaseModel):
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||
)
|
||||
title: str
|
||||
description: str | None = None
|
||||
decision_type: DecisionType = DecisionType.pending
|
||||
@@ -20,9 +25,9 @@ class DecisionCreate(BaseModel):
|
||||
escalation_note: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def topic_or_workstream_required(self) -> "DecisionCreate":
|
||||
if self.topic_id is None and self.workstream_id is None:
|
||||
raise ValueError("At least one of topic_id or workstream_id must be set")
|
||||
def topic_or_workplan_required(self) -> "DecisionCreate":
|
||||
if self.topic_id is None and self.workplan_id is None:
|
||||
raise ValueError("At least one of topic_id or workplan_id must be set")
|
||||
return self
|
||||
|
||||
|
||||
@@ -45,11 +50,10 @@ class DecisionUpdate(BaseModel):
|
||||
superseded_by: uuid.UUID | None = None
|
||||
|
||||
|
||||
class DecisionRead(BaseModel):
|
||||
class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
decision_type: DecisionType
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
|
||||
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
||||
@@ -21,7 +21,12 @@ class ExecutionIntentUpdate(BaseModel):
|
||||
|
||||
|
||||
class ExecutionIntentRead(BaseModel):
|
||||
workstream_id: uuid.UUID
|
||||
workplan_id: uuid.UUID
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID:
|
||||
return self.workplan_id
|
||||
execution_state: ExecutionState
|
||||
launch_mode: LaunchMode
|
||||
concurrency_mode: ConcurrencyMode
|
||||
@@ -31,7 +36,7 @@ class ExecutionIntentRead(BaseModel):
|
||||
|
||||
|
||||
class WorkplanQueueItem(BaseModel):
|
||||
workstream_id: uuid.UUID
|
||||
workplan_id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
status: str
|
||||
@@ -45,13 +50,18 @@ class WorkplanQueueItem(BaseModel):
|
||||
execution_group: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
eligible: bool
|
||||
blocked_by_workstream_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||
blocked_by_workplan_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def blocked_by_workstream_ids(self) -> list[uuid.UUID]:
|
||||
return self.blocked_by_workplan_ids
|
||||
blocked_by_task_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||
sort_key: list[str | int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LaunchRequestCreate(BaseModel):
|
||||
workstream_id: uuid.UUID
|
||||
workplan_id: uuid.UUID = Field(validation_alias=AliasChoices("workplan_id", "workstream_id"))
|
||||
requested_by: str = "dashboard"
|
||||
requested_actor: str | None = None
|
||||
launch_mode: LaunchMode = "queued"
|
||||
@@ -67,10 +77,15 @@ class LaunchRequestCreate(BaseModel):
|
||||
class LaunchRequestRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
workstream_id: uuid.UUID
|
||||
workplan_id: uuid.UUID
|
||||
requested_by: str
|
||||
requested_actor: str | None = None
|
||||
launch_mode: LaunchMode
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID:
|
||||
return self.workplan_id
|
||||
concurrency_mode: ConcurrencyMode
|
||||
priority: str | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||
|
||||
from api.models.extension_point import EPStatus
|
||||
|
||||
@@ -18,7 +18,10 @@ class EPCreate(BaseModel):
|
||||
status: EPStatus = EPStatus.open
|
||||
priority: str = "medium"
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||
)
|
||||
|
||||
|
||||
class EPUpdate(BaseModel):
|
||||
@@ -29,7 +32,10 @@ class EPUpdate(BaseModel):
|
||||
ep_type: str | None = None
|
||||
status: EPStatus | None = None
|
||||
priority: str | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||
)
|
||||
|
||||
|
||||
class EPRead(BaseModel):
|
||||
@@ -45,6 +51,10 @@ class EPRead(BaseModel):
|
||||
status: EPStatus
|
||||
priority: str
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID | None:
|
||||
return self.workplan_id
|
||||
updated_at: datetime
|
||||
|
||||
@@ -2,8 +2,10 @@ import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from api.classification import validate_classification
|
||||
from hub_core.schemas.managed_repo import (
|
||||
RepoCreate as CoreRepoCreate,
|
||||
RepoPathRegister,
|
||||
@@ -11,11 +13,73 @@ from hub_core.schemas.managed_repo import (
|
||||
)
|
||||
|
||||
|
||||
class RepoCreate(CoreRepoCreate):
|
||||
class ClassificationFields(BaseModel):
|
||||
category: str | None = None
|
||||
secondary_domains: list[str] | None = None
|
||||
capability_tags: list[str] | None = None
|
||||
business_stake: list[str] | None = None
|
||||
business_mechanics: list[str] | None = None
|
||||
classified_at: date | None = None
|
||||
classified_by: str | None = None
|
||||
standard_version: str | None = None
|
||||
|
||||
|
||||
def classification_fields_set(data: dict[str, Any]) -> bool:
|
||||
keys = (
|
||||
"category",
|
||||
"secondary_domains",
|
||||
"capability_tags",
|
||||
"business_stake",
|
||||
"business_mechanics",
|
||||
"classified_at",
|
||||
"classified_by",
|
||||
"standard_version",
|
||||
)
|
||||
return any(data.get(key) is not None for key in keys)
|
||||
|
||||
|
||||
def validate_repo_classification_fields(
|
||||
*,
|
||||
domain_slug: str,
|
||||
fields: dict[str, Any],
|
||||
require_complete: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate classification fields and return normalized values for persistence."""
|
||||
if not classification_fields_set(fields) and not require_complete:
|
||||
return fields
|
||||
|
||||
block = {
|
||||
"category": fields.get("category"),
|
||||
"domain": domain_slug,
|
||||
"secondary_domains": fields.get("secondary_domains") or [],
|
||||
"capability_tags": fields.get("capability_tags") or [],
|
||||
"business_stake": fields.get("business_stake") or [],
|
||||
"business_mechanics": fields.get("business_mechanics") or [],
|
||||
}
|
||||
if require_complete or fields.get("category") is not None:
|
||||
if block["category"] is None:
|
||||
raise HTTPException(status_code=422, detail="`category` is required when classification is provided")
|
||||
if classification_fields_set(fields) and block["category"] is not None:
|
||||
errors, warnings = validate_classification(block)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail={"classification_errors": errors, "warnings": warnings})
|
||||
return fields
|
||||
|
||||
|
||||
class RepoCreate(CoreRepoCreate, ClassificationFields):
|
||||
topic_id: uuid.UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_classification_on_create(self) -> "RepoCreate":
|
||||
validate_repo_classification_fields(
|
||||
domain_slug=self.domain_slug,
|
||||
fields=self.model_dump(),
|
||||
require_complete=classification_fields_set(self.model_dump()),
|
||||
)
|
||||
return self
|
||||
|
||||
class RepoUpdate(BaseModel):
|
||||
|
||||
class RepoUpdate(ClassificationFields):
|
||||
name: str | None = None
|
||||
local_path: str | None = None
|
||||
remote_url: str | None = None
|
||||
@@ -42,7 +106,7 @@ class RepoOnboardResult(BaseModel):
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
class RepoRead(CoreRepoRead):
|
||||
class RepoRead(CoreRepoRead, ClassificationFields):
|
||||
topic_id: uuid.UUID | None = None
|
||||
sbom_source: str | None = None
|
||||
last_sbom_at: datetime | None = None
|
||||
@@ -59,13 +123,17 @@ class DispatchTask(BaseModel):
|
||||
needs_human: bool
|
||||
|
||||
|
||||
class DispatchWorkstream(BaseModel):
|
||||
class DispatchWorkplan(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
status: str
|
||||
pending_tasks: list[DispatchTask]
|
||||
|
||||
|
||||
# Legacy alias
|
||||
DispatchWorkstream = DispatchWorkplan
|
||||
|
||||
|
||||
class PendingInterfaceChange(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
@@ -90,13 +158,17 @@ class ScopeIssueDetail(BaseModel):
|
||||
class RepoDispatch(BaseModel):
|
||||
repo_slug: str
|
||||
active_goal: dict[str, Any] | None
|
||||
active_workstreams: list[DispatchWorkstream]
|
||||
active_workplans: list[DispatchWorkplan]
|
||||
human_interventions: list[DispatchTask]
|
||||
pending_interface_changes: list[PendingInterfaceChange]
|
||||
scope_needs_review: bool
|
||||
scope_issue_details: list[ScopeIssueDetail]
|
||||
last_state_synced_at: datetime | None
|
||||
|
||||
@property
|
||||
def active_workstreams(self) -> list[DispatchWorkplan]:
|
||||
return self.active_workplans
|
||||
|
||||
|
||||
class RepoScopeHealth(BaseModel):
|
||||
repo_slug: str
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Self
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
|
||||
from api.models.task import TaskPriority, TaskStatus
|
||||
from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
|
||||
from api.task_status import normalize_task_status
|
||||
|
||||
|
||||
@@ -17,8 +18,7 @@ class TaskStatusMixin(BaseModel):
|
||||
return normalize_task_status(value)
|
||||
|
||||
|
||||
class TaskCreate(TaskStatusMixin):
|
||||
workstream_id: uuid.UUID
|
||||
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.todo
|
||||
@@ -96,10 +96,9 @@ class TaskStatusBulkSync(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class TaskRead(TaskStatusMixin):
|
||||
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
workstream_id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus
|
||||
@@ -114,8 +113,7 @@ class TaskRead(TaskStatusMixin):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TaskCountRead(TaskStatusMixin):
|
||||
workstream_id: uuid.UUID
|
||||
class TaskCountRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||
status: TaskStatus
|
||||
count: int
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||
|
||||
from api.models.technical_debt import TDStatus
|
||||
|
||||
@@ -35,7 +35,10 @@ class TDCreate(BaseModel):
|
||||
severity: str = "medium"
|
||||
status: TDStatus = TDStatus.open
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||
)
|
||||
|
||||
|
||||
class TDUpdate(BaseModel):
|
||||
@@ -45,7 +48,10 @@ class TDUpdate(BaseModel):
|
||||
debt_type: str | None = None
|
||||
severity: str | None = None
|
||||
status: TDStatus | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||
)
|
||||
|
||||
|
||||
class TDRead(BaseModel):
|
||||
@@ -61,7 +67,11 @@ class TDRead(BaseModel):
|
||||
severity: str
|
||||
status: TDStatus
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID | None:
|
||||
return self.workplan_id
|
||||
updated_at: datetime
|
||||
notes: list[TDNoteRead] = []
|
||||
|
||||
@@ -2,14 +2,19 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||
|
||||
|
||||
class TokenEventCreate(BaseModel):
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
task_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||
)
|
||||
repo_id: uuid.UUID | None = None
|
||||
session_id: str | None = None
|
||||
model: str | None = None
|
||||
@@ -32,14 +37,13 @@ class TokenEventCreate(BaseModel):
|
||||
raw_metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TokenEventRead(BaseModel):
|
||||
class TokenEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
task_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
session_id: str | None = None
|
||||
model: str | None = None
|
||||
@@ -90,7 +94,10 @@ class TokenEventPatch(BaseModel):
|
||||
tokens_in: int | None = None
|
||||
tokens_out: int | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||
)
|
||||
repo_id: uuid.UUID | None = None
|
||||
session_id: str | None = None
|
||||
note: str | None = None
|
||||
|
||||
107
api/schemas/workplan.py
Normal file
107
api/schemas/workplan.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from api.schemas.workplan_dependency import WorkplanDepStub
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
WorkplanStatus = Literal[
|
||||
"proposed",
|
||||
"ready",
|
||||
"active",
|
||||
"blocked",
|
||||
"backlog",
|
||||
"finished",
|
||||
"archived",
|
||||
]
|
||||
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
||||
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
|
||||
ConcurrencyMode = Literal["sequential", "parallel"]
|
||||
|
||||
|
||||
class WorkplanStatusMixin(BaseModel):
|
||||
@field_validator("status", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def _normalise_status(cls, value):
|
||||
return normalize_workplan_status(value)
|
||||
|
||||
|
||||
class WorkplanCreate(WorkplanStatusMixin):
|
||||
repo_id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkplanStatus = "active"
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
execution_state: ExecutionState = "manual"
|
||||
launch_mode: LaunchMode = "manual"
|
||||
concurrency_mode: ConcurrencyMode = "sequential"
|
||||
queue_rank: int | None = None
|
||||
execution_group: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkplanUpdate(WorkplanStatusMixin):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: WorkplanStatus | None = None
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
execution_state: ExecutionState | None = None
|
||||
launch_mode: LaunchMode | None = None
|
||||
concurrency_mode: ConcurrencyMode | None = None
|
||||
queue_rank: int | None = None
|
||||
execution_group: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
topic_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkplanRead(WorkplanStatusMixin):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkplanStatus
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
execution_state: ExecutionState = "manual"
|
||||
launch_mode: LaunchMode = "manual"
|
||||
concurrency_mode: ConcurrencyMode = "sequential"
|
||||
queue_rank: int | None = None
|
||||
execution_group: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkplanWithTaskCounts(WorkplanRead):
|
||||
tasks_total: int = 0
|
||||
tasks_wait: int = 0
|
||||
tasks_todo: int = 0
|
||||
tasks_progress: int = 0
|
||||
tasks_done: int = 0
|
||||
tasks_cancel: int = 0
|
||||
|
||||
|
||||
class WorkplanWithDeps(WorkplanWithTaskCounts):
|
||||
"""WorkplanWithTaskCounts enriched with dependency graph edges."""
|
||||
depends_on: list[WorkplanDepStub] = []
|
||||
blocks: list[WorkplanDepStub] = []
|
||||
blocked_reasons: list[dict] = []
|
||||
63
api/schemas/workplan_dependency.py
Normal file
63
api/schemas/workplan_dependency.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
|
||||
class WorkplanDependencyCreate(BaseModel):
|
||||
to_workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("to_workplan_id", "to_workstream_id"),
|
||||
)
|
||||
to_task_id: uuid.UUID | None = None
|
||||
relationship_type: str = "blocks"
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class WorkplanDependencyRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
from_workplan_id: uuid.UUID
|
||||
to_workplan_id: uuid.UUID | None = None
|
||||
to_task_id: uuid.UUID | None = None
|
||||
relationship_type: str
|
||||
description: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkplanDepStub(BaseModel):
|
||||
"""Minimal projection of the other end of a dependency edge."""
|
||||
dep_id: uuid.UUID
|
||||
target_type: str = "workplan"
|
||||
relationship_type: str = "blocks"
|
||||
workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||
)
|
||||
workplan_slug: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_slug", "workstream_slug"),
|
||||
)
|
||||
workplan_title: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("workplan_title", "workstream_title"),
|
||||
)
|
||||
task_id: uuid.UUID | None = None
|
||||
task_title: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID | None:
|
||||
return self.workplan_id
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def workstream_slug(self) -> str | None:
|
||||
return self.workplan_slug
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def workstream_title(self) -> str | None:
|
||||
return self.workplan_title
|
||||
@@ -1,106 +1,41 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
"""Legacy aliases — prefer ``api.schemas.workplan``."""
|
||||
from api.schemas.workplan import (
|
||||
ConcurrencyMode,
|
||||
ExecutionState,
|
||||
LaunchMode,
|
||||
WorkplanCreate,
|
||||
WorkplanRead,
|
||||
WorkplanStatus,
|
||||
WorkplanStatusMixin,
|
||||
WorkplanUpdate,
|
||||
WorkplanWithDeps,
|
||||
WorkplanWithTaskCounts,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
WorkstreamStatus = WorkplanStatus
|
||||
WorkstreamStatusMixin = WorkplanStatusMixin
|
||||
WorkstreamCreate = WorkplanCreate
|
||||
WorkstreamUpdate = WorkplanUpdate
|
||||
WorkstreamRead = WorkplanRead
|
||||
WorkstreamWithTaskCounts = WorkplanWithTaskCounts
|
||||
WorkstreamWithDeps = WorkplanWithDeps
|
||||
|
||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
|
||||
WorkstreamStatus = Literal[
|
||||
"proposed",
|
||||
"ready",
|
||||
"active",
|
||||
"blocked",
|
||||
"backlog",
|
||||
"finished",
|
||||
"archived",
|
||||
__all__ = [
|
||||
"WorkstreamStatus",
|
||||
"WorkstreamStatusMixin",
|
||||
"WorkstreamCreate",
|
||||
"WorkstreamUpdate",
|
||||
"WorkstreamRead",
|
||||
"WorkstreamWithTaskCounts",
|
||||
"WorkstreamWithDeps",
|
||||
"WorkplanStatus",
|
||||
"WorkplanStatusMixin",
|
||||
"WorkplanCreate",
|
||||
"WorkplanUpdate",
|
||||
"WorkplanRead",
|
||||
"WorkplanWithTaskCounts",
|
||||
"WorkplanWithDeps",
|
||||
"ExecutionState",
|
||||
"LaunchMode",
|
||||
"ConcurrencyMode",
|
||||
]
|
||||
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
||||
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
|
||||
ConcurrencyMode = Literal["sequential", "parallel"]
|
||||
|
||||
|
||||
class WorkstreamStatusMixin(BaseModel):
|
||||
@field_validator("status", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def _normalise_status(cls, value):
|
||||
return normalize_workstream_status(value)
|
||||
|
||||
|
||||
class WorkstreamCreate(WorkstreamStatusMixin):
|
||||
topic_id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus = "active"
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
execution_state: ExecutionState = "manual"
|
||||
launch_mode: LaunchMode = "manual"
|
||||
concurrency_mode: ConcurrencyMode = "sequential"
|
||||
queue_rank: int | None = None
|
||||
execution_group: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkstreamUpdate(WorkstreamStatusMixin):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus | None = None
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
execution_state: ExecutionState | None = None
|
||||
launch_mode: LaunchMode | None = None
|
||||
concurrency_mode: ConcurrencyMode | None = None
|
||||
queue_rank: int | None = None
|
||||
execution_group: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkstreamRead(WorkstreamStatusMixin):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID
|
||||
repo_id: uuid.UUID | None = None
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
execution_state: ExecutionState = "manual"
|
||||
launch_mode: LaunchMode = "manual"
|
||||
concurrency_mode: ConcurrencyMode = "sequential"
|
||||
queue_rank: int | None = None
|
||||
execution_group: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkstreamWithTaskCounts(WorkstreamRead):
|
||||
tasks_total: int = 0
|
||||
tasks_wait: int = 0
|
||||
tasks_todo: int = 0
|
||||
tasks_progress: int = 0
|
||||
tasks_done: int = 0
|
||||
tasks_cancel: int = 0
|
||||
|
||||
|
||||
class WorkstreamWithDeps(WorkstreamWithTaskCounts):
|
||||
"""WorkstreamWithTaskCounts enriched with dependency graph edges."""
|
||||
depends_on: list[WorkstreamDepStub] = []
|
||||
blocks: list[WorkstreamDepStub] = []
|
||||
blocked_reasons: list[dict] = []
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
"""Legacy aliases — prefer ``api.schemas.workplan_dependency``."""
|
||||
from api.schemas.workplan_dependency import (
|
||||
WorkplanDepStub,
|
||||
WorkplanDependencyCreate,
|
||||
WorkplanDependencyRead,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
WorkstreamDependencyCreate = WorkplanDependencyCreate
|
||||
WorkstreamDependencyRead = WorkplanDependencyRead
|
||||
WorkstreamDepStub = WorkplanDepStub
|
||||
|
||||
|
||||
class WorkstreamDependencyCreate(BaseModel):
|
||||
to_workstream_id: uuid.UUID | None = None
|
||||
to_task_id: uuid.UUID | None = None
|
||||
relationship_type: str = "blocks"
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class WorkstreamDependencyRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
from_workstream_id: uuid.UUID
|
||||
to_workstream_id: uuid.UUID | None = None
|
||||
to_task_id: uuid.UUID | None = None
|
||||
relationship_type: str
|
||||
description: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkstreamDepStub(BaseModel):
|
||||
"""Minimal projection of the other end of a dependency edge."""
|
||||
dep_id: uuid.UUID
|
||||
target_type: str = "workstream"
|
||||
relationship_type: str = "blocks"
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workstream_slug: str | None = None
|
||||
workstream_title: str | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
task_title: str | None = None
|
||||
description: str | None = None
|
||||
__all__ = [
|
||||
"WorkstreamDependencyCreate",
|
||||
"WorkstreamDependencyRead",
|
||||
"WorkstreamDepStub",
|
||||
"WorkplanDependencyCreate",
|
||||
"WorkplanDependencyRead",
|
||||
"WorkplanDepStub",
|
||||
]
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
|
||||
EXECUTION_STATES = {
|
||||
@@ -57,7 +57,7 @@ PRIORITY_RANK = {
|
||||
"low": 3,
|
||||
}
|
||||
|
||||
CLOSED_WORKSTREAM_STATUSES = {"finished", "archived"}
|
||||
CLOSED_WORKPLAN_STATUSES = {"finished", "archived"}
|
||||
|
||||
|
||||
def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) -> str:
|
||||
@@ -71,19 +71,24 @@ def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False)
|
||||
return "queued"
|
||||
|
||||
|
||||
def workstream_blockers(
|
||||
workstream_id: Any,
|
||||
def workplan_blockers(
|
||||
workplan_id: Any,
|
||||
dependency_targets: dict[Any, list[Any]],
|
||||
workstream_status: dict[Any, str],
|
||||
workplan_status: dict[Any, str],
|
||||
workstream_id: Any = None,
|
||||
) -> list[Any]:
|
||||
scope_id = workplan_id if workplan_id is not None else workstream_id
|
||||
blockers = []
|
||||
for target_id in dependency_targets.get(workstream_id, []):
|
||||
target_status = normalize_workstream_status(workstream_status.get(target_id))
|
||||
if target_status not in CLOSED_WORKSTREAM_STATUSES:
|
||||
for target_id in dependency_targets.get(scope_id, []):
|
||||
target_status = normalize_workplan_status(workplan_status.get(target_id))
|
||||
if target_status not in CLOSED_WORKPLAN_STATUSES:
|
||||
blockers.append(target_id)
|
||||
return blockers
|
||||
|
||||
|
||||
workstream_blockers = workplan_blockers
|
||||
|
||||
|
||||
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
|
||||
priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower()
|
||||
execution_state = str(getattr(workstream, "execution_state", "") or "manual").strip().lower()
|
||||
|
||||
@@ -4,14 +4,16 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
TASK_STARTED_STATUS = "progress"
|
||||
TASK_NOT_STARTED_STATUS = "todo"
|
||||
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
|
||||
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
|
||||
|
||||
# Legacy alias
|
||||
normalize_workstream_status = normalize_workplan_status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleTransitionResult:
|
||||
@@ -26,13 +28,15 @@ def should_activate_parent_for_task_start(
|
||||
*,
|
||||
previous_task_status: Any,
|
||||
new_task_status: Any,
|
||||
parent_workstream_status: Any,
|
||||
parent_workplan_status: Any = None,
|
||||
parent_workstream_status: Any = None,
|
||||
) -> bool:
|
||||
"""Return whether a task start should move its parent to active."""
|
||||
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
|
||||
return (
|
||||
status_value(previous_task_status) == TASK_NOT_STARTED_STATUS
|
||||
and status_value(new_task_status) == TASK_STARTED_STATUS
|
||||
and normalize_workstream_status(parent_workstream_status)
|
||||
and normalize_workplan_status(parent_status)
|
||||
in PARENT_ACTIVATION_STATUSES
|
||||
)
|
||||
|
||||
@@ -44,12 +48,14 @@ def has_active_task_status(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
||||
|
||||
def should_activate_parent_for_active_tasks(
|
||||
*,
|
||||
parent_workstream_status: Any,
|
||||
parent_workplan_status: Any = None,
|
||||
parent_workstream_status: Any = None,
|
||||
task_statuses: list[Any] | tuple[Any, ...],
|
||||
) -> bool:
|
||||
"""Return whether existing task state implies an active parent workstream."""
|
||||
"""Return whether existing task state implies an active parent workplan."""
|
||||
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
|
||||
return (
|
||||
normalize_workstream_status(parent_workstream_status)
|
||||
normalize_workplan_status(parent_status)
|
||||
in PARENT_ACTIVATION_STATUSES
|
||||
and has_active_task_status(task_statuses)
|
||||
)
|
||||
@@ -59,46 +65,54 @@ def activate_parent_for_task_start(
|
||||
*,
|
||||
previous_task_status: Any,
|
||||
new_task_status: Any,
|
||||
parent_workstream: Any,
|
||||
parent_workplan: Any = None,
|
||||
parent_workstream: Any = None,
|
||||
) -> bool:
|
||||
"""Activate a planning-state parent workstream when real task work starts."""
|
||||
if parent_workstream is None:
|
||||
"""Activate a planning-state parent workplan when real task work starts."""
|
||||
parent = parent_workplan if parent_workplan is not None else parent_workstream
|
||||
if parent is None:
|
||||
return False
|
||||
if not should_activate_parent_for_task_start(
|
||||
previous_task_status=previous_task_status,
|
||||
new_task_status=new_task_status,
|
||||
parent_workstream_status=getattr(parent_workstream, "status", None),
|
||||
parent_workplan_status=getattr(parent, "status", None),
|
||||
parent_workstream_status=getattr(parent, "status", None),
|
||||
):
|
||||
return False
|
||||
parent_workstream.status = "active"
|
||||
parent.status = "active"
|
||||
return True
|
||||
|
||||
|
||||
def transition_workstream_status(
|
||||
workstream: Any,
|
||||
def transition_workplan_status(
|
||||
workplan: Any,
|
||||
target_status: Any,
|
||||
) -> LifecycleTransitionResult:
|
||||
"""Apply a canonical workstream status transition."""
|
||||
previous_status = normalize_workstream_status(getattr(workstream, "status", None))
|
||||
normalised_target = normalize_workstream_status(target_status)
|
||||
workstream.status = normalised_target
|
||||
"""Apply a canonical workplan status transition."""
|
||||
previous_status = normalize_workplan_status(getattr(workplan, "status", None))
|
||||
normalised_target = normalize_workplan_status(target_status)
|
||||
workplan.status = normalised_target
|
||||
return LifecycleTransitionResult(
|
||||
entity_type="workstream",
|
||||
entity_type="workplan",
|
||||
previous_status=previous_status,
|
||||
target_status=normalised_target,
|
||||
changed=previous_status != normalised_target,
|
||||
)
|
||||
|
||||
|
||||
transition_workstream_status = transition_workplan_status
|
||||
|
||||
|
||||
def transition_task_status(
|
||||
task: Any,
|
||||
target_status: Any,
|
||||
*,
|
||||
parent_workplan: Any = None,
|
||||
parent_workstream: Any = None,
|
||||
previous_task_status: Any = None,
|
||||
status_coercer: Any = None,
|
||||
) -> LifecycleTransitionResult:
|
||||
"""Apply a task status transition and activate the parent when work starts."""
|
||||
parent = parent_workplan if parent_workplan is not None else parent_workstream
|
||||
previous_status = status_value(
|
||||
getattr(task, "status", None)
|
||||
if previous_task_status is None
|
||||
@@ -109,7 +123,8 @@ def transition_task_status(
|
||||
parent_activated = activate_parent_for_task_start(
|
||||
previous_task_status=previous_status,
|
||||
new_task_status=normalised_target,
|
||||
parent_workstream=parent_workstream,
|
||||
parent_workplan=parent,
|
||||
parent_workstream=parent,
|
||||
)
|
||||
return LifecycleTransitionResult(
|
||||
entity_type="task",
|
||||
|
||||
@@ -20,7 +20,7 @@ from api.models.managed_repo import ManagedRepo
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.topic import Topic
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.recently_on_scope import (
|
||||
RecentlyOnScopeFailedDomain,
|
||||
RecentlyOnScopeHourlyRun,
|
||||
@@ -344,11 +344,11 @@ async def _list_topics(domain_id: uuid.UUID, session: AsyncSession) -> list[Topi
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workstream]:
|
||||
async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workplan]:
|
||||
result = await session.execute(
|
||||
select(Workstream)
|
||||
.where(_in(Workstream.topic_id, topic_ids))
|
||||
.order_by(Workstream.updated_at.desc(), Workstream.created_at.desc())
|
||||
select(Workplan)
|
||||
.where(_in(Workplan.topic_id, topic_ids))
|
||||
.order_by(Workplan.updated_at.desc(), Workplan.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -356,7 +356,7 @@ async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -
|
||||
async def _list_tasks(workstream_ids: list[uuid.UUID], session: AsyncSession) -> list[Task]:
|
||||
result = await session.execute(
|
||||
select(Task)
|
||||
.where(_in(Task.workstream_id, workstream_ids))
|
||||
.where(_in(Task.workplan_id, workstream_ids))
|
||||
.order_by(Task.updated_at.desc(), Task.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -370,7 +370,7 @@ async def _list_recent_decisions(
|
||||
) -> list[Decision]:
|
||||
result = await session.execute(
|
||||
select(Decision)
|
||||
.where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workstream_id, workstream_ids)))
|
||||
.where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workplan_id, workstream_ids)))
|
||||
.where(
|
||||
or_(
|
||||
_between(Decision.created_at, window),
|
||||
@@ -397,7 +397,7 @@ async def _list_recent_progress(
|
||||
.where(
|
||||
or_(
|
||||
_in(ProgressEvent.topic_id, topic_ids),
|
||||
_in(ProgressEvent.workstream_id, workstream_ids),
|
||||
_in(ProgressEvent.workplan_id, workstream_ids),
|
||||
_in(ProgressEvent.task_id, task_ids),
|
||||
_in(ProgressEvent.decision_id, decision_ids),
|
||||
)
|
||||
@@ -550,7 +550,8 @@ def _progress_data(event: ProgressEvent) -> dict[str, Any]:
|
||||
"event_type": event.event_type,
|
||||
"summary": event.summary,
|
||||
"author": event.author,
|
||||
"workstream_id": str(event.workstream_id) if event.workstream_id else None,
|
||||
"workplan_id": str(event.workplan_id) if event.workplan_id else None,
|
||||
"workstream_id": str(event.workplan_id) if event.workplan_id else None,
|
||||
"task_id": str(event.task_id) if event.task_id else None,
|
||||
"decision_id": str(event.decision_id) if event.decision_id else None,
|
||||
}
|
||||
@@ -569,7 +570,7 @@ def _decision_data(decision: Decision) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _workstream_data(workstream: Workstream) -> dict[str, Any]:
|
||||
def _workstream_data(workstream: Workplan) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(workstream.id),
|
||||
"slug": workstream.slug,
|
||||
@@ -584,7 +585,7 @@ def _workstream_data(workstream: Workstream) -> dict[str, Any]:
|
||||
def _task_data(task: Task) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(task.id),
|
||||
"workstream_id": str(task.workstream_id),
|
||||
"workstream_id": str(task.workplan_id),
|
||||
"title": task.title,
|
||||
"status": _enum_value(task.status),
|
||||
"priority": _enum_value(task.priority),
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from api.services.lifecycle import status_value
|
||||
from api.task_status import CANONICAL_TASK_STATUSES
|
||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
||||
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
|
||||
|
||||
|
||||
class ReconciliationClass(str, Enum):
|
||||
@@ -22,11 +22,11 @@ class StateChangeClassification:
|
||||
follow_up: str
|
||||
|
||||
|
||||
WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"}
|
||||
WRITE_THROUGH_WORKPLAN_STATUSES = {"proposed", "ready", "active", "backlog"}
|
||||
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
|
||||
|
||||
|
||||
def classify_workstream_status_change(
|
||||
def classify_workplan_status_change(
|
||||
*,
|
||||
current_status: Any,
|
||||
target_status: Any,
|
||||
@@ -35,8 +35,8 @@ def classify_workstream_status_change(
|
||||
tasks_terminal: bool | None = None,
|
||||
) -> StateChangeClassification:
|
||||
"""Classify a UI-originated workstream status transition."""
|
||||
current = normalize_workstream_status(current_status)
|
||||
target = normalize_workstream_status(target_status)
|
||||
current = normalize_workplan_status(current_status)
|
||||
target = normalize_workplan_status(target_status)
|
||||
|
||||
if not file_backed:
|
||||
return StateChangeClassification(
|
||||
@@ -56,7 +56,7 @@ def classify_workstream_status_change(
|
||||
"status is unchanged",
|
||||
"no file update required",
|
||||
)
|
||||
if target in WRITE_THROUGH_WORKSTREAM_STATUSES and current not in CLOSED_WORKSTREAM_STATUSES:
|
||||
if target in WRITE_THROUGH_WORKPLAN_STATUSES and current not in CLOSED_WORKPLAN_STATUSES:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.WRITE_THROUGH,
|
||||
"open lifecycle transition can be represented in workplan frontmatter",
|
||||
@@ -93,6 +93,9 @@ def classify_workstream_status_change(
|
||||
)
|
||||
|
||||
|
||||
classify_workstream_status_change = classify_workplan_status_change
|
||||
|
||||
|
||||
def classify_task_status_change(
|
||||
*,
|
||||
current_status: Any,
|
||||
|
||||
@@ -41,9 +41,9 @@ def resolve_repo_path(repo: ManagedRepo | None) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def find_workplan_for_workstream(
|
||||
def find_workplan_for_workplan(
|
||||
repo: ManagedRepo | None,
|
||||
workstream_id: uuid.UUID,
|
||||
workplan_id: uuid.UUID,
|
||||
) -> WorkplanFileRef | None:
|
||||
repo_path = resolve_repo_path(repo)
|
||||
if repo_path is None:
|
||||
@@ -57,11 +57,15 @@ def find_workplan_for_workstream(
|
||||
continue
|
||||
for path in sorted(directory.glob("*.md")):
|
||||
meta = _frontmatter(path)
|
||||
if str(meta.get("state_hub_workstream_id", "")).strip().strip('"') == str(workstream_id):
|
||||
file_id = meta.get("state_hub_workplan_id") or meta.get("state_hub_workstream_id")
|
||||
if str(file_id or "").strip().strip('"') == str(workplan_id):
|
||||
return WorkplanFileRef(repo_path=repo_path, path=path, archived=archived)
|
||||
return None
|
||||
|
||||
|
||||
find_workplan_for_workstream = find_workplan_for_workplan
|
||||
|
||||
|
||||
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
|
||||
return _task_block_for_task(path, task_id) is not None
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||
CANONICAL_WORKPLAN_STATUSES: tuple[str, ...] = (
|
||||
"proposed",
|
||||
"ready",
|
||||
"active",
|
||||
@@ -17,22 +17,31 @@ CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||
"archived",
|
||||
)
|
||||
|
||||
LEGACY_WORKSTREAM_STATUS_ALIASES: dict[str, str] = {
|
||||
LEGACY_WORKPLAN_STATUS_ALIASES: dict[str, str] = {
|
||||
"todo": "ready",
|
||||
"done": "finished",
|
||||
"completed": "finished",
|
||||
"accepted": "finished",
|
||||
}
|
||||
|
||||
SUPPORTED_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||
*CANONICAL_WORKSTREAM_STATUSES,
|
||||
*LEGACY_WORKSTREAM_STATUS_ALIASES.keys(),
|
||||
SUPPORTED_WORKPLAN_STATUSES: tuple[str, ...] = (
|
||||
*CANONICAL_WORKPLAN_STATUSES,
|
||||
*LEGACY_WORKPLAN_STATUS_ALIASES.keys(),
|
||||
)
|
||||
|
||||
OPEN_WORKSTREAM_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
||||
CURRENT_WORKSTREAM_STATUSES: tuple[str, ...] = ("active", "blocked")
|
||||
CLOSED_WORKSTREAM_STATUSES: tuple[str, ...] = ("finished", "archived")
|
||||
PLANNING_WORKSTREAM_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
|
||||
OPEN_WORKPLAN_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
||||
CURRENT_WORKPLAN_STATUSES: tuple[str, ...] = ("active", "blocked")
|
||||
CLOSED_WORKPLAN_STATUSES: tuple[str, ...] = ("finished", "archived")
|
||||
PLANNING_WORKPLAN_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
|
||||
|
||||
# Legacy aliases (workstream terminology)
|
||||
CANONICAL_WORKSTREAM_STATUSES = CANONICAL_WORKPLAN_STATUSES
|
||||
LEGACY_WORKSTREAM_STATUS_ALIASES = LEGACY_WORKPLAN_STATUS_ALIASES
|
||||
SUPPORTED_WORKSTREAM_STATUSES = SUPPORTED_WORKPLAN_STATUSES
|
||||
OPEN_WORKSTREAM_STATUSES = OPEN_WORKPLAN_STATUSES
|
||||
CURRENT_WORKSTREAM_STATUSES = CURRENT_WORKPLAN_STATUSES
|
||||
CLOSED_WORKSTREAM_STATUSES = CLOSED_WORKPLAN_STATUSES
|
||||
PLANNING_WORKSTREAM_STATUSES = PLANNING_WORKPLAN_STATUSES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -42,26 +51,38 @@ class ReadyReviewStatus:
|
||||
changed_paths: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def normalize_workstream_status(status: Any, *, has_started: bool | None = None) -> str:
|
||||
def normalize_workplan_status(status: Any, *, has_started: bool | None = None) -> str:
|
||||
"""Return the canonical lifecycle status for a stored or legacy value."""
|
||||
value = _status_value(status)
|
||||
if value == "todo" and has_started:
|
||||
return "active"
|
||||
return LEGACY_WORKSTREAM_STATUS_ALIASES.get(value, value)
|
||||
return LEGACY_WORKPLAN_STATUS_ALIASES.get(value, value)
|
||||
|
||||
|
||||
def is_canonical_workstream_status(status: Any) -> bool:
|
||||
return _status_value(status) in CANONICAL_WORKSTREAM_STATUSES
|
||||
normalize_workstream_status = normalize_workplan_status
|
||||
|
||||
|
||||
def is_supported_workstream_status(status: Any) -> bool:
|
||||
return _status_value(status) in SUPPORTED_WORKSTREAM_STATUSES
|
||||
def is_canonical_workplan_status(status: Any) -> bool:
|
||||
return _status_value(status) in CANONICAL_WORKPLAN_STATUSES
|
||||
|
||||
|
||||
def workstream_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
||||
is_canonical_workstream_status = is_canonical_workplan_status
|
||||
|
||||
|
||||
def is_supported_workplan_status(status: Any) -> bool:
|
||||
return _status_value(status) in SUPPORTED_WORKPLAN_STATUSES
|
||||
|
||||
|
||||
is_supported_workstream_status = is_supported_workplan_status
|
||||
|
||||
|
||||
def workplan_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
||||
return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
|
||||
|
||||
|
||||
workstream_has_started = workplan_has_started
|
||||
|
||||
|
||||
def ready_review_status(
|
||||
repo_dir: str | Path,
|
||||
reviewed_against_commit: Any,
|
||||
|
||||
@@ -4,27 +4,36 @@ title: Domains — Reference
|
||||
|
||||
# Domains — Reference
|
||||
|
||||
The Domains page shows all registered project domains and the repositories
|
||||
associated with each one. Domains are the top-level organisational unit of the
|
||||
Custodian ecosystem.
|
||||
The Domains page shows the **14 fixed market domains** from the Repo
|
||||
Classification Standard. These replaced the old ad-hoc coordination domains
|
||||
(custodian, railiance, markitect, …) in STATE-WP-0065.
|
||||
|
||||
---
|
||||
|
||||
## What is a domain?
|
||||
|
||||
A domain corresponds to one of the six tracked project areas:
|
||||
A domain is an intended **market / user segment** — not a project org unit.
|
||||
Each registered repo has exactly one primary domain (from its
|
||||
`.repo-classification.yaml`), stored on `managed_repos.domain_id`.
|
||||
|
||||
| Slug | Project |
|
||||
| Slug | Segment |
|
||||
|------|---------|
|
||||
| `custodian` | The Custodian agent system itself |
|
||||
| `railiance` | DevOps & infrastructure reliability |
|
||||
| `markitect` | Knowledge artifact management |
|
||||
| `coulomb_social` | Co-creation marketplace |
|
||||
| `personhood` | Rights & obligations framework |
|
||||
| `foerster_capabilities` | Agency capability taxonomy |
|
||||
| `infotech` | Developers, platforms, internal tooling users |
|
||||
| `financials` | Finance, trading, payments |
|
||||
| `communication` | Messaging, social, collaboration |
|
||||
| `consumer` | General consumers |
|
||||
| `health` | Healthcare, wellness |
|
||||
| `industrials` | Manufacturing, logistics |
|
||||
| `energy` | Energy sector |
|
||||
| `utilities` | Utilities infrastructure |
|
||||
| `materials` | Materials / commodities |
|
||||
| `realestate` | Property, housing |
|
||||
| `crypto` | Crypto / web3 |
|
||||
| `agents` | AI-native agent users |
|
||||
| `space` | Space industry |
|
||||
| `government` | Civic, public sector |
|
||||
|
||||
Each domain has a slug (URL-friendly identifier), a human-readable name, an
|
||||
optional description, and a status.
|
||||
Canon: `the-custodian/canon/standards/repo-classification-standard_v1.0.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -32,63 +41,21 @@ optional description, and a status.
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| **active** | Live domain — topics, workstreams, and tasks are being tracked |
|
||||
| **archived** | Soft-deleted; no active work. Fails to archive if active topics exist |
|
||||
| **active** | Live domain — repos and workplans may reference it |
|
||||
| **archived** | Retired; no new registrations |
|
||||
|
||||
---
|
||||
|
||||
## KPI row
|
||||
## Relationship to repos and workplans
|
||||
|
||||
Four counters at the top of the page:
|
||||
|
||||
| Counter | Meaning |
|
||||
|---------|---------|
|
||||
| Total domains | All registered domains regardless of status |
|
||||
| Active | Domains with status `active` |
|
||||
| Total repos | Sum of all registered repositories across all domains |
|
||||
| Newest domain | Name of the most recently created domain |
|
||||
- **Repos** are the primary anchor — classification file is source of truth.
|
||||
- **Workplans** require `repo_id`; market domain is derived from the repo.
|
||||
- **Topics** are optional legacy tags; workplan frontmatter `domain:` may still
|
||||
use old coordination slugs — the consistency checker maps these to market domains.
|
||||
|
||||
---
|
||||
|
||||
## Domain cards
|
||||
## Related
|
||||
|
||||
One card per domain showing:
|
||||
|
||||
- **Slug** — monospace identifier
|
||||
- **Status badge** — green `active` or grey `archived`
|
||||
- **Name** — display name
|
||||
- **Description** — first 160 characters
|
||||
- **Repos** — list of registered repositories for this domain, each showing name, local path, and remote URL
|
||||
|
||||
---
|
||||
|
||||
## RecentlyOnScope
|
||||
|
||||
The `Domains / RecentlyOnScope` page generates deterministic Markdown digests
|
||||
for a selected domain. The range parameter defaults to `1h` and accepts compact
|
||||
durations such as `15m`, `6h`, or `1d`.
|
||||
|
||||
Generated reports are written under the configured State Hub report directory,
|
||||
defaulting to `reports/recently-on-scope/<domain_slug>/`. The dashboard lists
|
||||
those Markdown files and previews the raw report content.
|
||||
|
||||
---
|
||||
|
||||
## Managing domains
|
||||
|
||||
Via MCP:
|
||||
|
||||
```
|
||||
create_domain(slug="my_project", name="My Project", description="…")
|
||||
rename_domain(slug="old_slug", new_slug="new_slug", new_name="New Name")
|
||||
archive_domain(slug="my_project") # fails if active topics exist
|
||||
```
|
||||
|
||||
Via Makefile:
|
||||
|
||||
```bash
|
||||
make add-domain SLUG=my_project NAME="My Project"
|
||||
make rename-domain OLD_SLUG=my_project NEW_SLUG=myproject NEW_NAME="My Project"
|
||||
```
|
||||
|
||||
*Domains are never hard-deleted — only archived.*
|
||||
- **[Repos](/docs/repos)** — portfolio view with category / capability filters
|
||||
- **[Repo Integration](/docs/repo-integration)** — onboarding with classification file
|
||||
@@ -5,18 +5,25 @@ title: Repos — Reference
|
||||
# Repos — Reference
|
||||
|
||||
The Repos page shows every repository registered in the Custodian ecosystem,
|
||||
their SBOM ingestion status, and a domain-grouped coverage map.
|
||||
their **classification** (category, market domain, capabilities, business stake),
|
||||
SBOM ingestion status, and a domain-grouped coverage map.
|
||||
|
||||
---
|
||||
|
||||
## What is a managed repo?
|
||||
|
||||
A managed repo is a git repository that has been registered with the state hub
|
||||
via `custodian register-project` or `register_repo()`. Registration records the
|
||||
repo's slug, domain, local path, and optional remote URL. Once registered, the
|
||||
repo receives a `CLAUDE.custodian.md` integration suggestion, an onboarding
|
||||
workstream with 4 tasks for the repo agent, and is eligible for SBOM ingestion
|
||||
and the ADR-001 workplan validator.
|
||||
A managed repo is a git repository registered with State Hub. Registration is
|
||||
**classification-driven**:
|
||||
|
||||
1. Commit `.repo-classification.yaml` per the Repo Classification Standard.
|
||||
2. Run `make register-from-classification REPO=<slug>` (or use the MCP tool
|
||||
`register_repo_from_classification`).
|
||||
|
||||
The file is the source of truth; the hub stores a validated copy on
|
||||
`managed_repos` (category, domain, capability_tags, business_stake, provenance).
|
||||
|
||||
Legacy `custodian register-project` still works for agent onboarding but should
|
||||
be followed by classification registration.
|
||||
|
||||
For the full onboarding journey see **[Repo Integration](/docs/repo-integration)**.
|
||||
|
||||
@@ -27,69 +34,56 @@ For the full onboarding journey see **[Repo Integration](/docs/repo-integration)
|
||||
| Card | Meaning |
|
||||
|------|---------|
|
||||
| **Registered Repos** | Active repos only (status = active) |
|
||||
| **Domains** | Count of distinct domain slugs across registered repos |
|
||||
| **Market Domains** | Distinct primary domains across registered repos |
|
||||
| **Categories** | Distinct work categories (experimental, tooling, product, …) |
|
||||
| **SBOM Ingested** | Repos with at least one SBOM snapshot |
|
||||
| **SBOM Gaps** | Repos with no ingested SBOM — red border when > 0 |
|
||||
|
||||
---
|
||||
|
||||
## Coverage Map
|
||||
## Portfolio by Category
|
||||
|
||||
Groups repos by domain. Each domain block shows:
|
||||
|
||||
- **Domain name** with SBOM, EP, and TD chip indicators
|
||||
- **SBOM chip** — green ✓ if all repos in the domain are ingested, amber ⚠ if any gap exists
|
||||
- **EPs chip** — count of open/in-progress extension points for this domain
|
||||
- **TDs chip** — count of open/in-progress technical debt items for this domain
|
||||
- **Repo table** — one row per repo with SBOM status, package count, and local path
|
||||
|
||||
Rows with no SBOM are highlighted in amber.
|
||||
Groups repos by `category` (experimental, research, project, tooling, product,
|
||||
business). Each block shows domain, capabilities, business stake, and who
|
||||
classified the repo (`human` vs `migration`).
|
||||
|
||||
---
|
||||
|
||||
## Filters
|
||||
## Coverage Map
|
||||
|
||||
Groups repos by **market domain**. Each domain block shows SBOM, EP, and TD
|
||||
chips plus per-repo classification columns.
|
||||
|
||||
---
|
||||
|
||||
## Filters (All Repos Table)
|
||||
|
||||
| Filter | Effect |
|
||||
|--------|--------|
|
||||
| **Domain** | Show repos for a single domain only |
|
||||
| **Gaps only** | Toggle to show only repos without an ingested SBOM |
|
||||
| **Market domain** | Primary domain slug |
|
||||
| **Category** | Repo work category |
|
||||
| **Capability** | Repos tagged with a capability |
|
||||
| **Business stake** | Repos affecting a business responsibility area |
|
||||
| **DoI tier** | Definition of Integrated tier |
|
||||
| **Gaps only** | Repos without ingested SBOM |
|
||||
|
||||
---
|
||||
|
||||
## Consistency (C-24)
|
||||
|
||||
The ADR-001 consistency checker warns when a registered repo lacks a valid
|
||||
`.repo-classification.yaml` on disk. Migration-derived rows (`classified_by:
|
||||
migration`) get an explanatory note until a human-reviewed file is committed.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding a new repo
|
||||
|
||||
See **[Repo Integration](/docs/repo-integration)** for the full journey.
|
||||
|
||||
Quick reference:
|
||||
Use the **Add Repo** form or:
|
||||
|
||||
```bash
|
||||
# From the repo root — registers, writes CLAUDE.custodian.md, creates onboarding tasks
|
||||
custodian register-project --domain <slug>
|
||||
# 1. Author classification file in the repo
|
||||
# 2. Register / reclassify
|
||||
make register-from-classification PATH=/path/to/repo
|
||||
make fix-consistency REPO=<slug>
|
||||
```
|
||||
|
||||
## Ingesting a repo's SBOM
|
||||
|
||||
```bash
|
||||
# Auto-detects lockfile at repo root
|
||||
cd ~/state-hub
|
||||
make ingest-sbom REPO=<slug> REPO_PATH=/absolute/path
|
||||
|
||||
# Multi-ecosystem repo — scan all lockfiles recursively
|
||||
make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=/absolute/path
|
||||
```
|
||||
|
||||
Supported lockfile formats: `uv.lock`, `requirements.txt`, `package-lock.json`,
|
||||
`yarn.lock`, `Cargo.lock`, `.terraform.lock.hcl`.
|
||||
|
||||
---
|
||||
|
||||
## Infra-only repos
|
||||
|
||||
Repos with no lockfile (Ansible, shell scripts) can be registered for inventory
|
||||
purposes. The SBOM gap is expected and can be left as-is. Terraform providers
|
||||
are auto-detected via `.terraform.lock.hcl` when using `--scan`.
|
||||
|
||||
---
|
||||
|
||||
*SBOM snapshots are replaced on each ingest — not appended. The last ingestion
|
||||
timestamp is recorded on the managed_repo row.*
|
||||
|
||||
@@ -102,14 +102,28 @@ const repoRows = repos
|
||||
const integrating = !!integratingBySlug[r.slug];
|
||||
const doiEntry = doiBySlug[r.slug] ?? null;
|
||||
const doiTier = doiEntry?.tier ?? "none";
|
||||
const category = r.category ?? "—";
|
||||
const capList = r.capability_tags ?? [];
|
||||
const stakeList = r.business_stake ?? [];
|
||||
const capTags = capList.length
|
||||
? capList.slice(0, 3).join(", ") + (capList.length > 3 ? "…" : "")
|
||||
: "—";
|
||||
const classifiedBy = r.classified_by ?? "—";
|
||||
return {
|
||||
_id: r.id,
|
||||
_domSlug: domSlug,
|
||||
_category: category,
|
||||
_capList: capList,
|
||||
_stakeList: stakeList,
|
||||
_hasSbom: hasSbom,
|
||||
_integrating: integrating,
|
||||
_doiTier: doiTier,
|
||||
repo: r.slug,
|
||||
domain: domName,
|
||||
category: category,
|
||||
capTags: capTags,
|
||||
businessStake: stakeList.length ? stakeList.slice(0, 3).join(", ") : "—",
|
||||
classifiedBy: classifiedBy,
|
||||
status: integrating ? "⚙ integrating" : "ready",
|
||||
path: r.local_path ?? "—",
|
||||
sbom: hasSbom ? `✓ ${lastScan}` : "⚠ not ingested",
|
||||
@@ -153,9 +167,13 @@ display(html`<div class="kpi-row">
|
||||
<p class="big-num">${repoRows.length}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Domains</h3>
|
||||
<h3>Market Domains</h3>
|
||||
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Categories</h3>
|
||||
<p class="big-num">${new Set(repoRows.map(r => r._category).filter(c => c !== "—")).size}</p>
|
||||
</div>
|
||||
<div class="card ${integratingCount > 0 ? 'card-integrating' : ''}">
|
||||
<h3>Integrating</h3>
|
||||
<p class="big-num">${integratingCount}</p>
|
||||
@@ -240,6 +258,8 @@ if (domainBlocks.length === 0) {
|
||||
<table class="repo-table">
|
||||
<thead><tr>
|
||||
<th>Repo</th>
|
||||
<th>Category</th>
|
||||
<th>Capabilities</th>
|
||||
<th>DoI Tier</th>
|
||||
<th>Status</th>
|
||||
<th>SBOM</th>
|
||||
@@ -249,6 +269,8 @@ if (domainBlocks.length === 0) {
|
||||
<tbody>
|
||||
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
|
||||
<td class="repo-cell"><code>${r.repo}</code></td>
|
||||
<td>${r.category}</td>
|
||||
<td class="path-cell" title=${r.capTags}>${r.capTags}</td>
|
||||
<td>${_doiBadge(r._doiTier)}</td>
|
||||
<td>${r._integrating
|
||||
? html`<span class="chip chip-integrating">⚙ integrating</span>`
|
||||
@@ -266,18 +288,65 @@ if (domainBlocks.length === 0) {
|
||||
}
|
||||
```
|
||||
|
||||
## Portfolio by Category
|
||||
|
||||
```js
|
||||
const byCategory = {};
|
||||
for (const r of repoRows) {
|
||||
const key = r._category === "—" ? "unclassified" : r._category;
|
||||
(byCategory[key] = byCategory[key] ?? []).push(r);
|
||||
}
|
||||
const categoryBlocks = Object.entries(byCategory).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (categoryBlocks.length > 0) {
|
||||
display(html`<h2 style="margin-top:2rem">Portfolio by Category</h2>
|
||||
<div class="domain-list">
|
||||
${categoryBlocks.map(([cat, rows]) => html`
|
||||
<div class="domain-block">
|
||||
<div class="domain-header">
|
||||
<span class="domain-name">${cat}</span>
|
||||
<span class="domain-chips">
|
||||
<span class="chip chip-neutral">${rows.length} repo(s)</span>
|
||||
<span class="chip chip-neutral">${new Set(rows.map(r => r._domSlug)).size} domain(s)</span>
|
||||
</span>
|
||||
</div>
|
||||
<table class="repo-table">
|
||||
<thead><tr>
|
||||
<th>Repo</th><th>Domain</th><th>Capabilities</th><th>Business stake</th><th>Classified</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${rows.map(r => html`<tr>
|
||||
<td class="repo-cell"><code>${r.repo}</code></td>
|
||||
<td>${r.domain}</td>
|
||||
<td class="path-cell">${r.capTags}</td>
|
||||
<td class="path-cell">${r.businessStake}</td>
|
||||
<td>${r.classifiedBy}</td>
|
||||
</tr>`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`)}
|
||||
</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
## All Repos Table
|
||||
|
||||
```js
|
||||
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Domain", value: "all"});
|
||||
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Market domain", value: "all"});
|
||||
const categoryFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._category).filter(c => c !== "—")).values()], {label: "Category", value: "all"});
|
||||
const capabilityFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._capList)).values()].sort(), {label: "Capability", value: "all"});
|
||||
const stakeFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._stakeList)).values()].sort(), {label: "Business stake", value: "all"});
|
||||
const doiFilter = Inputs.select(["all", "none", "core", "standard", "full"], {label: "DoI tier", value: "all"});
|
||||
const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false});
|
||||
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">${domainFilter}${doiFilter}${gapFilter}</div>`);
|
||||
display(html`<div class="filter-bar">${domainFilter}${categoryFilter}${capabilityFilter}${stakeFilter}${doiFilter}${gapFilter}</div>`);
|
||||
```
|
||||
|
||||
```js
|
||||
const filteredRows = repoRows.filter(r =>
|
||||
(domainFilter.value === "all" || r._domSlug === domainFilter.value) &&
|
||||
(categoryFilter.value === "all" || r._category === categoryFilter.value) &&
|
||||
(capabilityFilter.value === "all" || r._capList.includes(capabilityFilter.value)) &&
|
||||
(stakeFilter.value === "all" || r._stakeList.includes(stakeFilter.value)) &&
|
||||
(doiFilter.value === "all" || r._doiTier === doiFilter.value) &&
|
||||
(!gapFilter.value || !r._hasSbom)
|
||||
);
|
||||
@@ -285,6 +354,10 @@ const filteredRows = repoRows.filter(r =>
|
||||
display(Inputs.table(filteredRows.map(r => ({
|
||||
Repo: r.repo,
|
||||
Domain: r.domain,
|
||||
Category: r.category,
|
||||
Capabilities: r.capTags,
|
||||
"Business stake": r.businessStake,
|
||||
Classified: r.classifiedBy,
|
||||
"DoI Tier": DOI_TIER_LABEL[r._doiTier] ?? r._doiTier,
|
||||
Status: r.status,
|
||||
SBOM: r.sbom,
|
||||
|
||||
@@ -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}` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
635
scripts/register_from_classification.py
Normal file
635
scripts/register_from_classification.py
Normal file
@@ -0,0 +1,635 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Idempotent registration from committed ``.repo-classification.yaml`` (STATE-WP-0065 P3).
|
||||
|
||||
Reads classification from a repo checkout, validates against the canon allowed-values,
|
||||
and upserts the ``managed_repos`` row (create or update classification + market domain).
|
||||
|
||||
Usage:
|
||||
python scripts/register_from_classification.py --repo-path /path/to/repo [--dry-run]
|
||||
python scripts/register_from_classification.py --slug state-hub [--dry-run]
|
||||
python scripts/register_from_classification.py --bulk [--dry-run]
|
||||
python scripts/register_from_classification.py --help
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from sqlalchemy import select # noqa: E402
|
||||
|
||||
from api.classification import ( # noqa: E402
|
||||
CLASSIFICATION_FILENAME,
|
||||
ClassificationData,
|
||||
load_classification_file,
|
||||
)
|
||||
from api.config import settings # noqa: E402
|
||||
from api.database import async_session_factory, engine # noqa: E402
|
||||
from api.models.domain import Domain # noqa: E402
|
||||
from api.models.managed_repo import ManagedRepo # noqa: E402
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
_HAS_HTTPX = True
|
||||
except ImportError:
|
||||
_HAS_HTTPX = False
|
||||
|
||||
Outcome = Literal["registered", "updated", "skipped", "invalid"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RowResult:
|
||||
slug: str
|
||||
path: str
|
||||
outcome: Outcome
|
||||
detail: str = ""
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistrationReport:
|
||||
results: list[RowResult] = field(default_factory=list)
|
||||
|
||||
def add(self, result: RowResult) -> None:
|
||||
self.results.append(result)
|
||||
|
||||
def counts(self) -> dict[str, int]:
|
||||
totals = {"registered": 0, "updated": 0, "skipped": 0, "invalid": 0}
|
||||
for row in self.results:
|
||||
totals[row.outcome] = totals.get(row.outcome, 0) + 1
|
||||
return totals
|
||||
|
||||
def render_text(self) -> str:
|
||||
lines = ["register-from-classification report", ""]
|
||||
for row in self.results:
|
||||
lines.append(f" [{row.outcome:10}] {row.slug:30} {row.detail}")
|
||||
for warning in row.warnings:
|
||||
lines.append(f" warn: {warning}")
|
||||
counts = self.counts()
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Summary: "
|
||||
f"registered={counts['registered']} "
|
||||
f"updated={counts['updated']} "
|
||||
f"skipped={counts['skipped']} "
|
||||
f"invalid={counts['invalid']}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"summary": self.counts(),
|
||||
"results": [
|
||||
{
|
||||
"slug": r.slug,
|
||||
"path": r.path,
|
||||
"outcome": r.outcome,
|
||||
"detail": r.detail,
|
||||
"warnings": r.warnings,
|
||||
}
|
||||
for r in self.results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return slug or "repo"
|
||||
|
||||
|
||||
def _parse_classified_at(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _git_value(repo_path: Path, args: list[str]) -> str | None:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", *args],
|
||||
cwd=repo_path,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip() or None
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _git_root(path: Path) -> Path:
|
||||
root = _git_value(path, ["rev-parse", "--show-toplevel"])
|
||||
return Path(root) if root else path.resolve()
|
||||
|
||||
|
||||
def _resolve_repo_path_for_host(repo: ManagedRepo) -> str | None:
|
||||
hostname = socket.gethostname()
|
||||
host_paths = repo.host_paths or {}
|
||||
path = host_paths.get(hostname) or repo.local_path
|
||||
if path and Path(path).is_dir():
|
||||
return path
|
||||
for candidate in host_paths.values():
|
||||
if candidate and Path(candidate).is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _classification_changed(repo: ManagedRepo, data: ClassificationData, domain_id) -> bool:
|
||||
if repo.domain_id != domain_id:
|
||||
return True
|
||||
fields = (
|
||||
("category", data.category),
|
||||
("secondary_domains", data.secondary_domains or None),
|
||||
("capability_tags", data.capability_tags or None),
|
||||
("business_stake", data.business_stake or None),
|
||||
("business_mechanics", data.business_mechanics or None),
|
||||
("classified_at", _parse_classified_at(data.classified_at)),
|
||||
("classified_by", data.classified_by),
|
||||
("standard_version", data.standard_version),
|
||||
)
|
||||
for attr, new_val in fields:
|
||||
if getattr(repo, attr) != new_val:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _apply_classification(repo: ManagedRepo, data: ClassificationData, domain_id) -> None:
|
||||
repo.domain_id = domain_id
|
||||
repo.category = data.category
|
||||
repo.secondary_domains = data.secondary_domains or None
|
||||
repo.capability_tags = data.capability_tags or None
|
||||
repo.business_stake = data.business_stake or None
|
||||
repo.business_mechanics = data.business_mechanics or None
|
||||
repo.classified_at = _parse_classified_at(data.classified_at)
|
||||
repo.classified_by = data.classified_by
|
||||
repo.standard_version = data.standard_version
|
||||
|
||||
|
||||
async def _get_domain_id(session, market_slug: str):
|
||||
result = await session.execute(select(Domain).where(Domain.slug == market_slug))
|
||||
domain = result.scalar_one_or_none()
|
||||
if domain is None:
|
||||
raise ValueError(f"Market domain '{market_slug}' not found in domains table")
|
||||
return domain.id
|
||||
|
||||
|
||||
async def _get_repo_by_slug(session, slug: str) -> ManagedRepo | None:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _api_request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
api_base: str,
|
||||
body: dict | None = None,
|
||||
) -> tuple[int, Any]:
|
||||
if not _HAS_HTTPX:
|
||||
return (0, {"_error": "httpx not installed"})
|
||||
url = api_base.rstrip("/") + path
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.request(method, url, json=body)
|
||||
if response.status_code == 204:
|
||||
return response.status_code, None
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = {"_raw": response.text}
|
||||
return response.status_code, payload
|
||||
except httpx.HTTPError as exc:
|
||||
return (0, {"_error": str(exc)})
|
||||
|
||||
|
||||
async def _upsert_via_db(
|
||||
*,
|
||||
slug: str,
|
||||
repo_path: Path,
|
||||
data: ClassificationData,
|
||||
dry_run: bool,
|
||||
report: RegistrationReport,
|
||||
) -> None:
|
||||
git_root = _git_root(repo_path)
|
||||
remote_url = _git_value(git_root, ["remote", "get-url", "origin"])
|
||||
git_fingerprint = _git_value(git_root, ["rev-list", "--max-parents=0", "HEAD"])
|
||||
hostname = socket.gethostname()
|
||||
display_name = git_root.name.replace("-", " ").replace("_", " ").title()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
domain_id = await _get_domain_id(session, data.domain)
|
||||
except ValueError as exc:
|
||||
if dry_run:
|
||||
report.add(
|
||||
RowResult(
|
||||
slug,
|
||||
str(git_root),
|
||||
"skipped",
|
||||
f"dry-run: {exc}",
|
||||
)
|
||||
)
|
||||
return
|
||||
report.add(RowResult(slug, str(git_root), "invalid", str(exc)))
|
||||
return
|
||||
|
||||
repo = await _get_repo_by_slug(session, slug)
|
||||
if repo is None:
|
||||
if dry_run:
|
||||
report.add(
|
||||
RowResult(
|
||||
slug,
|
||||
str(git_root),
|
||||
"registered",
|
||||
f"would create repo under domain '{data.domain}' (dry-run)",
|
||||
)
|
||||
)
|
||||
return
|
||||
repo = ManagedRepo(
|
||||
domain_id=domain_id,
|
||||
slug=slug,
|
||||
name=display_name,
|
||||
local_path=str(git_root),
|
||||
host_paths={hostname: str(git_root)},
|
||||
remote_url=remote_url,
|
||||
git_fingerprint=git_fingerprint,
|
||||
status="active",
|
||||
)
|
||||
_apply_classification(repo, data, domain_id)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
report.add(
|
||||
RowResult(slug, str(git_root), "registered", f"domain={data.domain}")
|
||||
)
|
||||
return
|
||||
|
||||
warnings: list[str] = []
|
||||
if not _classification_changed(repo, data, domain_id):
|
||||
if repo.local_path != str(git_root):
|
||||
if dry_run:
|
||||
report.add(
|
||||
RowResult(
|
||||
slug,
|
||||
str(git_root),
|
||||
"skipped",
|
||||
"classification unchanged; would refresh local_path (dry-run)",
|
||||
)
|
||||
)
|
||||
return
|
||||
repo.local_path = str(git_root)
|
||||
host_paths = dict(repo.host_paths or {})
|
||||
host_paths[hostname] = str(git_root)
|
||||
repo.host_paths = host_paths
|
||||
if remote_url:
|
||||
repo.remote_url = remote_url
|
||||
if git_fingerprint:
|
||||
repo.git_fingerprint = git_fingerprint
|
||||
await session.commit()
|
||||
report.add(
|
||||
RowResult(slug, str(git_root), "skipped", "paths refreshed only")
|
||||
)
|
||||
return
|
||||
report.add(
|
||||
RowResult(slug, str(git_root), "skipped", "classification already current")
|
||||
)
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
report.add(
|
||||
RowResult(
|
||||
slug,
|
||||
str(git_root),
|
||||
"updated",
|
||||
f"would update classification (domain={data.domain}) (dry-run)",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
_apply_classification(repo, data, domain_id)
|
||||
repo.local_path = str(git_root)
|
||||
host_paths = dict(repo.host_paths or {})
|
||||
host_paths[hostname] = str(git_root)
|
||||
repo.host_paths = host_paths
|
||||
if remote_url:
|
||||
repo.remote_url = remote_url
|
||||
if git_fingerprint:
|
||||
repo.git_fingerprint = git_fingerprint
|
||||
await session.commit()
|
||||
report.add(
|
||||
RowResult(slug, str(git_root), "updated", f"domain={data.domain}")
|
||||
)
|
||||
|
||||
|
||||
async def _upsert_via_api(
|
||||
*,
|
||||
slug: str,
|
||||
repo_path: Path,
|
||||
data: ClassificationData,
|
||||
dry_run: bool,
|
||||
api_base: str,
|
||||
report: RegistrationReport,
|
||||
) -> None:
|
||||
git_root = _git_root(repo_path)
|
||||
remote_url = _git_value(git_root, ["remote", "get-url", "origin"])
|
||||
git_fingerprint = _git_value(git_root, ["rev-list", "--max-parents=0", "HEAD"])
|
||||
hostname = socket.gethostname()
|
||||
display_name = git_root.name.replace("-", " ").replace("_", " ").title()
|
||||
|
||||
status, existing = _api_request("GET", f"/repos/{slug}", api_base=api_base)
|
||||
if status == 404 or (isinstance(existing, dict) and existing.get("detail")):
|
||||
existing = None
|
||||
elif status == 0:
|
||||
report.add(
|
||||
RowResult(
|
||||
slug,
|
||||
str(git_root),
|
||||
"invalid",
|
||||
f"API unreachable: {existing.get('_error', existing)}",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
patch_body = {
|
||||
"category": data.category,
|
||||
"secondary_domains": data.secondary_domains,
|
||||
"capability_tags": data.capability_tags,
|
||||
"business_stake": data.business_stake,
|
||||
"business_mechanics": data.business_mechanics,
|
||||
"classified_at": data.classified_at,
|
||||
"classified_by": data.classified_by,
|
||||
"standard_version": data.standard_version,
|
||||
"domain_slug": data.domain,
|
||||
"local_path": str(git_root),
|
||||
"remote_url": remote_url,
|
||||
"git_fingerprint": git_fingerprint,
|
||||
}
|
||||
|
||||
if existing is None:
|
||||
if dry_run:
|
||||
report.add(
|
||||
RowResult(
|
||||
slug,
|
||||
str(git_root),
|
||||
"registered",
|
||||
f"would POST /repos/ domain={data.domain} (dry-run)",
|
||||
)
|
||||
)
|
||||
return
|
||||
post_body = {
|
||||
"domain_slug": data.domain,
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"local_path": str(git_root),
|
||||
"host_paths": {hostname: str(git_root)},
|
||||
"remote_url": remote_url,
|
||||
"git_fingerprint": git_fingerprint,
|
||||
}
|
||||
code, created = _api_request("POST", "/repos/", api_base=api_base, body=post_body)
|
||||
if code not in (200, 201):
|
||||
detail = created.get("detail", created) if isinstance(created, dict) else created
|
||||
report.add(RowResult(slug, str(git_root), "invalid", f"POST failed: {detail}"))
|
||||
return
|
||||
code, updated = _api_request(
|
||||
"PATCH", f"/repos/{slug}", api_base=api_base, body=patch_body
|
||||
)
|
||||
if code != 200:
|
||||
detail = updated.get("detail", updated) if isinstance(updated, dict) else updated
|
||||
report.add(
|
||||
RowResult(
|
||||
slug,
|
||||
str(git_root),
|
||||
"invalid",
|
||||
f"created repo but classification PATCH failed: {detail}",
|
||||
)
|
||||
)
|
||||
return
|
||||
report.add(RowResult(slug, str(git_root), "registered", f"domain={data.domain}"))
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
report.add(
|
||||
RowResult(
|
||||
slug,
|
||||
str(git_root),
|
||||
"updated",
|
||||
f"would PATCH /repos/{slug} domain={data.domain} (dry-run)",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
code, updated = _api_request(
|
||||
"PATCH", f"/repos/{slug}", api_base=api_base, body=patch_body
|
||||
)
|
||||
if code != 200:
|
||||
detail = updated.get("detail", updated) if isinstance(updated, dict) else updated
|
||||
report.add(RowResult(slug, str(git_root), "invalid", f"PATCH failed: {detail}"))
|
||||
return
|
||||
_api_request(
|
||||
"POST",
|
||||
f"/repos/{slug}/paths",
|
||||
api_base=api_base,
|
||||
body={"host": hostname, "path": str(git_root)},
|
||||
)
|
||||
report.add(RowResult(slug, str(git_root), "updated", f"domain={data.domain}"))
|
||||
|
||||
|
||||
async def register_one(
|
||||
*,
|
||||
slug: str,
|
||||
repo_path: Path,
|
||||
dry_run: bool = False,
|
||||
use_api: bool = False,
|
||||
api_base: str | None = None,
|
||||
report: RegistrationReport | None = None,
|
||||
) -> RowResult:
|
||||
"""Register or update a single repo from its classification file."""
|
||||
report = report or RegistrationReport()
|
||||
git_root = _git_root(repo_path)
|
||||
data, errors, warnings = load_classification_file(git_root)
|
||||
if data is None:
|
||||
result = RowResult(
|
||||
slug,
|
||||
str(git_root),
|
||||
"invalid",
|
||||
"; ".join(errors) or "classification invalid",
|
||||
warnings=warnings,
|
||||
)
|
||||
report.add(result)
|
||||
return result
|
||||
|
||||
if use_api:
|
||||
await _upsert_via_api(
|
||||
slug=slug,
|
||||
repo_path=git_root,
|
||||
data=data,
|
||||
dry_run=dry_run,
|
||||
api_base=api_base or settings.api_base,
|
||||
report=report,
|
||||
)
|
||||
else:
|
||||
await _upsert_via_db(
|
||||
slug=slug,
|
||||
repo_path=git_root,
|
||||
data=data,
|
||||
dry_run=dry_run,
|
||||
report=report,
|
||||
)
|
||||
return report.results[-1]
|
||||
|
||||
|
||||
async def _bulk_targets(session) -> list[tuple[str, str]]:
|
||||
result = await session.execute(
|
||||
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
|
||||
)
|
||||
targets: list[tuple[str, str]] = []
|
||||
for repo in result.scalars().all():
|
||||
path = _resolve_repo_path_for_host(repo)
|
||||
if path:
|
||||
targets.append((repo.slug, path))
|
||||
return targets
|
||||
|
||||
|
||||
async def run_registration(args: argparse.Namespace) -> RegistrationReport:
|
||||
report = RegistrationReport()
|
||||
use_api = args.api and not args.db
|
||||
|
||||
if args.bulk:
|
||||
async with async_session_factory() as session:
|
||||
targets = await _bulk_targets(session)
|
||||
if not targets:
|
||||
report.add(
|
||||
RowResult("(bulk)", "", "skipped", "no active repos with accessible local paths")
|
||||
)
|
||||
return report
|
||||
for slug, path in targets:
|
||||
await register_one(
|
||||
slug=slug,
|
||||
repo_path=Path(path),
|
||||
dry_run=args.dry_run,
|
||||
use_api=use_api,
|
||||
api_base=args.api_base,
|
||||
report=report,
|
||||
)
|
||||
return report
|
||||
|
||||
if args.repo_path:
|
||||
repo_path = Path(args.repo_path).expanduser().resolve()
|
||||
slug = args.slug or _slugify(_git_root(repo_path).name)
|
||||
await register_one(
|
||||
slug=slug,
|
||||
repo_path=repo_path,
|
||||
dry_run=args.dry_run,
|
||||
use_api=use_api,
|
||||
api_base=args.api_base,
|
||||
report=report,
|
||||
)
|
||||
return report
|
||||
|
||||
if args.slug:
|
||||
async with async_session_factory() as session:
|
||||
repo = await _get_repo_by_slug(session, args.slug)
|
||||
if repo is None:
|
||||
report.add(RowResult(args.slug, "", "invalid", "repo slug not found in DB"))
|
||||
return report
|
||||
path = _resolve_repo_path_for_host(repo)
|
||||
if not path:
|
||||
report.add(
|
||||
RowResult(
|
||||
args.slug,
|
||||
"",
|
||||
"invalid",
|
||||
"no accessible local path (local_path / host_paths)",
|
||||
)
|
||||
)
|
||||
return report
|
||||
await register_one(
|
||||
slug=args.slug,
|
||||
repo_path=Path(path),
|
||||
dry_run=args.dry_run,
|
||||
use_api=use_api,
|
||||
api_base=args.api_base,
|
||||
report=report,
|
||||
)
|
||||
return report
|
||||
|
||||
raise SystemExit("Specify --repo-path PATH, --slug SLUG, or --bulk")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Register or update managed_repos from .repo-classification.yaml",
|
||||
)
|
||||
parser.add_argument("--repo-path", metavar="PATH", help="Local git checkout path")
|
||||
parser.add_argument(
|
||||
"--slug",
|
||||
metavar="SLUG",
|
||||
help="Registered repo slug (required with --bulk omitted unless --repo-path given)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bulk",
|
||||
action="store_true",
|
||||
help="All active registered repos with accessible local paths",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Report actions without writing to DB/API",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api",
|
||||
action="store_true",
|
||||
help="Upsert via REST API (default: direct DB session)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
action="store_true",
|
||||
help="Force direct DB session (overrides --api)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-base",
|
||||
default=settings.api_base,
|
||||
help=f"State Hub API base URL (default: {settings.api_base})",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON report")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.bulk:
|
||||
if args.repo_path:
|
||||
parser.error("--bulk cannot be combined with --repo-path")
|
||||
elif args.repo_path:
|
||||
pass
|
||||
elif args.slug:
|
||||
pass
|
||||
else:
|
||||
parser.error("Specify one of --repo-path PATH, --slug SLUG, or --bulk")
|
||||
|
||||
report = asyncio.run(run_registration(args))
|
||||
if args.json:
|
||||
print(json.dumps(report.to_dict(), indent=2))
|
||||
else:
|
||||
print(report.render_text())
|
||||
|
||||
counts = report.counts()
|
||||
return 1 if counts["invalid"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
334
scripts/spine_migration_data.py
Normal file
334
scripts/spine_migration_data.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""Constants and mappings for STATE-WP-0065 P1 spine migration.
|
||||
|
||||
Shared by the Alembic revision and the dry-run report script.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
# Deterministic UUIDs for market-domain rows (stable across runs).
|
||||
_MARKET_DOMAIN_NAMESPACE = uuid.UUID("8dc7d106-11e2-41df-b512-89ed69d2a65f")
|
||||
|
||||
# 14 fixed market domains from Repo Classification Standard v1.0 §6.
|
||||
MARKET_DOMAINS: list[tuple[str, str]] = [
|
||||
("infotech", "Infotech"),
|
||||
("financials", "Financials"),
|
||||
("communication", "Communication"),
|
||||
("consumer", "Consumer"),
|
||||
("health", "Health"),
|
||||
("industrials", "Industrials"),
|
||||
("energy", "Energy"),
|
||||
("utilities", "Utilities"),
|
||||
("materials", "Materials"),
|
||||
("realestate", "Real Estate"),
|
||||
("crypto", "Crypto"),
|
||||
("agents", "Agents"),
|
||||
("space", "Space"),
|
||||
("government", "Government"),
|
||||
]
|
||||
|
||||
# Old coordination-domain slugs (pre-migration ``domains`` table) → market domain.
|
||||
OLD_DOMAIN_TO_MARKET: dict[str, str] = {
|
||||
"custodian": "infotech",
|
||||
"railiance": "financials",
|
||||
"markitect": "communication",
|
||||
"coulomb_social": "communication",
|
||||
"personhood": "government",
|
||||
"foerster_capabilities": "agents",
|
||||
# Extended coordination domains (beyond the original 6 canonical seeds).
|
||||
"capabilities": "agents",
|
||||
"canon": "infotech",
|
||||
"citation_evidence": "infotech",
|
||||
"helix_forge": "infotech",
|
||||
"inter_hub": "infotech",
|
||||
"netkingdom": "communication",
|
||||
"stack": "infotech",
|
||||
"vergabe_teilnahme": "government",
|
||||
"whynot": "consumer",
|
||||
"test_domain_v2": "infotech",
|
||||
}
|
||||
|
||||
# Best-effort reverse map for downgrade (lossy: many market domains → one old slug).
|
||||
MARKET_TO_OLD_DOMAIN: dict[str, str] = {
|
||||
market: old
|
||||
for old, market in OLD_DOMAIN_TO_MARKET.items()
|
||||
}
|
||||
|
||||
# Legacy coordination domains restored on downgrade.
|
||||
OLD_COORDINATION_DOMAINS: list[tuple[str, str]] = [
|
||||
("custodian", "The Custodian"),
|
||||
("railiance", "Railiance"),
|
||||
("markitect", "Markitect"),
|
||||
("coulomb_social", "Coulomb.social"),
|
||||
("personhood", "Personhood"),
|
||||
("foerster_capabilities", "Foerster Capabilities"),
|
||||
]
|
||||
|
||||
# Human-reviewed classifications for the 11 custodian-domain fixture repos.
|
||||
REPO_CLASSIFICATIONS: dict[str, dict] = {
|
||||
"the-custodian": {
|
||||
"category": "research",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": ["agents"],
|
||||
"capability_tags": [
|
||||
"governance",
|
||||
"knowledge",
|
||||
"coordination",
|
||||
"policy",
|
||||
"documentation",
|
||||
],
|
||||
"business_stake": ["technology", "operations", "intelligence", "execution"],
|
||||
"business_mechanics": ["intention", "control", "coordination", "adaptation"],
|
||||
},
|
||||
"inter-hub": {
|
||||
"category": "research",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": ["agents"],
|
||||
"capability_tags": [
|
||||
"governance",
|
||||
"observability",
|
||||
"platform",
|
||||
"coordination",
|
||||
"orchestration",
|
||||
],
|
||||
"business_stake": ["technology", "intelligence", "operations"],
|
||||
"business_mechanics": ["control", "coordination", "adaptation"],
|
||||
},
|
||||
"state-hub": {
|
||||
"category": "tooling",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": ["agents"],
|
||||
"capability_tags": [
|
||||
"coordination",
|
||||
"knowledge",
|
||||
"platform",
|
||||
"observability",
|
||||
"governance",
|
||||
],
|
||||
"business_stake": [
|
||||
"technology",
|
||||
"operations",
|
||||
"product",
|
||||
"intelligence",
|
||||
"automation",
|
||||
],
|
||||
"business_mechanics": ["coordination", "control", "operation", "adaptation"],
|
||||
},
|
||||
"hub-core": {
|
||||
"category": "tooling",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": [],
|
||||
"capability_tags": ["platform", "configuration", "orchestration"],
|
||||
"business_stake": ["technology", "execution", "product"],
|
||||
"business_mechanics": ["operation"],
|
||||
},
|
||||
"activity-core": {
|
||||
"category": "tooling",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": ["agents"],
|
||||
"capability_tags": [
|
||||
"workflow",
|
||||
"orchestration",
|
||||
"automation",
|
||||
"coordination",
|
||||
"observability",
|
||||
],
|
||||
"business_stake": ["technology", "operations", "automation", "execution"],
|
||||
"business_mechanics": ["coordination", "operation", "adaptation"],
|
||||
},
|
||||
"issue-core": {
|
||||
"category": "tooling",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": ["agents"],
|
||||
"capability_tags": [
|
||||
"workflow",
|
||||
"coordination",
|
||||
"orchestration",
|
||||
"traceability",
|
||||
],
|
||||
"business_stake": ["technology", "product", "operations", "automation"],
|
||||
"business_mechanics": ["coordination", "operation"],
|
||||
},
|
||||
"kaizen-agentic": {
|
||||
"category": "tooling",
|
||||
"domain": "agents",
|
||||
"secondary_domains": ["infotech"],
|
||||
"capability_tags": [
|
||||
"orchestration",
|
||||
"automation",
|
||||
"coordination",
|
||||
"knowledge",
|
||||
"documentation",
|
||||
],
|
||||
"business_stake": [
|
||||
"technology",
|
||||
"product",
|
||||
"automation",
|
||||
"people",
|
||||
"intelligence",
|
||||
],
|
||||
"business_mechanics": [
|
||||
"intention",
|
||||
"coordination",
|
||||
"operation",
|
||||
"adaptation",
|
||||
],
|
||||
},
|
||||
"llm-connect": {
|
||||
"category": "tooling",
|
||||
"domain": "agents",
|
||||
"secondary_domains": ["infotech"],
|
||||
"capability_tags": [
|
||||
"orchestration",
|
||||
"model-routing",
|
||||
"configuration",
|
||||
"automation",
|
||||
],
|
||||
"business_stake": ["technology", "product", "automation"],
|
||||
"business_mechanics": ["operation", "adaptation"],
|
||||
},
|
||||
"ops-bridge": {
|
||||
"category": "tooling",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": [],
|
||||
"capability_tags": [
|
||||
"operations",
|
||||
"access-control",
|
||||
"platform",
|
||||
"observability",
|
||||
"orchestration",
|
||||
],
|
||||
"business_stake": ["operations", "technology", "automation"],
|
||||
"business_mechanics": ["control", "operation", "adaptation"],
|
||||
},
|
||||
"ops-warden": {
|
||||
"category": "tooling",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": [],
|
||||
"capability_tags": [
|
||||
"identity",
|
||||
"access-control",
|
||||
"security",
|
||||
"policy",
|
||||
"audit",
|
||||
"governance",
|
||||
],
|
||||
"business_stake": ["technology", "operations", "legal", "automation"],
|
||||
"business_mechanics": ["control", "operation"],
|
||||
},
|
||||
"email-connect": {
|
||||
"category": "tooling",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": ["communication"],
|
||||
"capability_tags": [
|
||||
"evidence",
|
||||
"traceability",
|
||||
"source-management",
|
||||
"automation",
|
||||
],
|
||||
"business_stake": ["technology", "operations", "legal"],
|
||||
"business_mechanics": ["operation", "coordination"],
|
||||
},
|
||||
}
|
||||
|
||||
# Repo discrepancy resolution (STATE-WP-0065 §P1 data migration).
|
||||
REPO_DISPOSITIONS: dict[str, dict] = {
|
||||
"markitect-project": {
|
||||
"action": "relink_to",
|
||||
"target_slug": "markitect-main",
|
||||
"archive": True,
|
||||
},
|
||||
"railiance-bootstrap": {
|
||||
"action": "archive",
|
||||
},
|
||||
"railiance-hosts": {
|
||||
"action": "archive",
|
||||
},
|
||||
"vergabe_teilnahme": {
|
||||
"action": "collapse_into",
|
||||
"target_slug": "vergabe-teilnahme",
|
||||
"archive": True,
|
||||
},
|
||||
}
|
||||
|
||||
# Fallback repo slug for orphan workplans after backfill.
|
||||
FALLBACK_REPO_SLUG = "state-hub"
|
||||
|
||||
STANDARD_VERSION = "1.0"
|
||||
|
||||
|
||||
def market_domain_uuid(slug: str) -> str:
|
||||
"""Deterministic UUID string for a market-domain slug."""
|
||||
return str(uuid.uuid5(_MARKET_DOMAIN_NAMESPACE, f"state-hub.market-domain.{slug}"))
|
||||
|
||||
|
||||
def old_domain_uuid(slug: str) -> str:
|
||||
"""Deterministic UUID string for a legacy coordination-domain slug."""
|
||||
return str(uuid.uuid5(_MARKET_DOMAIN_NAMESPACE, f"state-hub.coordination-domain.{slug}"))
|
||||
|
||||
|
||||
def derive_classification(repo_slug: str, old_domain_slug: str | None) -> dict:
|
||||
"""Return a classification dict for *repo_slug*.
|
||||
|
||||
Uses committed ``REPO_CLASSIFICATIONS`` when present; otherwise derives a
|
||||
migration-time classification from the old coordination domain.
|
||||
"""
|
||||
if repo_slug in REPO_CLASSIFICATIONS:
|
||||
data = dict(REPO_CLASSIFICATIONS[repo_slug])
|
||||
data.setdefault("classified_by", "human")
|
||||
return data
|
||||
|
||||
market = OLD_DOMAIN_TO_MARKET.get(old_domain_slug or "", "infotech")
|
||||
|
||||
# Domain-specific heuristics for repos without committed classification files.
|
||||
category = "project"
|
||||
secondary_domains: list[str] = []
|
||||
capability_tags: list[str] = []
|
||||
business_stake: list[str] = []
|
||||
business_mechanics: list[str] = []
|
||||
|
||||
if old_domain_slug == "custodian":
|
||||
category = "tooling"
|
||||
capability_tags = ["platform"]
|
||||
business_stake = ["technology", "operations"]
|
||||
elif old_domain_slug == "railiance":
|
||||
category = "project"
|
||||
capability_tags = ["platform", "operations"]
|
||||
business_stake = ["technology", "operations"]
|
||||
elif old_domain_slug == "markitect":
|
||||
category = "project"
|
||||
capability_tags = ["knowledge", "documentation"]
|
||||
business_stake = ["technology", "product"]
|
||||
elif old_domain_slug == "coulomb_social":
|
||||
category = "experimental"
|
||||
capability_tags = ["marketplace", "collaboration"]
|
||||
business_stake = ["product", "sales"]
|
||||
elif old_domain_slug == "personhood":
|
||||
category = "research"
|
||||
capability_tags = ["governance", "policy"]
|
||||
business_stake = ["legal", "technology", "intelligence"]
|
||||
business_mechanics = ["intention", "control"]
|
||||
elif old_domain_slug == "foerster_capabilities":
|
||||
category = "research"
|
||||
capability_tags = ["knowledge"]
|
||||
business_stake = ["intelligence", "technology"]
|
||||
|
||||
return {
|
||||
"category": category,
|
||||
"domain": market,
|
||||
"secondary_domains": secondary_domains,
|
||||
"capability_tags": capability_tags,
|
||||
"business_stake": business_stake,
|
||||
"business_mechanics": business_mechanics,
|
||||
"classified_by": "migration",
|
||||
}
|
||||
|
||||
|
||||
def migration_provenance() -> dict:
|
||||
"""Provenance fields applied during Alembic backfill."""
|
||||
return {
|
||||
"classified_at": date.today().isoformat(),
|
||||
"classified_by": "migration",
|
||||
"standard_version": STANDARD_VERSION,
|
||||
}
|
||||
206
scripts/spine_migration_dry_run.py
Normal file
206
scripts/spine_migration_dry_run.py
Normal file
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dry-run report for STATE-WP-0065 P1 spine migration.
|
||||
|
||||
Prints would-be classification, domain, repo-disposition, and workplan-anchor
|
||||
changes without applying them. Requires a live PostgreSQL connection (same
|
||||
DATABASE_URL as the API).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from sqlalchemy import text # noqa: E402
|
||||
from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
|
||||
|
||||
from api.database import async_session_factory, engine # noqa: E402
|
||||
from scripts.spine_migration_data import ( # noqa: E402
|
||||
FALLBACK_REPO_SLUG,
|
||||
MARKET_DOMAINS,
|
||||
OLD_DOMAIN_TO_MARKET,
|
||||
REPO_CLASSIFICATIONS,
|
||||
REPO_DISPOSITIONS,
|
||||
derive_classification,
|
||||
market_domain_uuid,
|
||||
)
|
||||
|
||||
|
||||
def _section(title: str) -> None:
|
||||
print()
|
||||
print("=" * 72)
|
||||
print(title)
|
||||
print("=" * 72)
|
||||
|
||||
|
||||
async def _report_domains(session: AsyncSession) -> None:
|
||||
_section("Domain spine replacement")
|
||||
result = await session.execute(
|
||||
text("SELECT slug, name FROM domains ORDER BY slug")
|
||||
)
|
||||
current = result.fetchall()
|
||||
current_slugs = {row[0] for row in current}
|
||||
print(f"Current domains ({len(current)}):")
|
||||
for slug, name in current:
|
||||
mapped = OLD_DOMAIN_TO_MARKET.get(slug, "(no mapping — would delete)")
|
||||
print(f" {slug:25} → {mapped}")
|
||||
|
||||
print(f"\nMarket domains to insert ({len(MARKET_DOMAINS)}):")
|
||||
for slug, name in MARKET_DOMAINS:
|
||||
flag = "exists" if slug in current_slugs else "NEW"
|
||||
print(f" [{flag:5}] {slug:20} {name:20} id={market_domain_uuid(slug)}")
|
||||
|
||||
|
||||
async def _report_classifications(session: AsyncSession) -> None:
|
||||
_section("Repo classification backfill")
|
||||
rows = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT mr.slug, mr.status, d.slug AS old_domain
|
||||
FROM managed_repos mr
|
||||
JOIN domains d ON d.id = mr.domain_id
|
||||
ORDER BY mr.slug
|
||||
"""
|
||||
)
|
||||
)
|
||||
from_file = 0
|
||||
derived = 0
|
||||
for repo_slug, status, old_domain in rows:
|
||||
cls = derive_classification(repo_slug, old_domain)
|
||||
source = "fixture" if repo_slug in REPO_CLASSIFICATIONS else "derived"
|
||||
if source == "fixture":
|
||||
from_file += 1
|
||||
else:
|
||||
derived += 1
|
||||
print(
|
||||
f" {repo_slug:30} [{status:8}] "
|
||||
f"{old_domain:20} → {cls['category']:12} · {cls['domain']:15} "
|
||||
f"({source}, by={cls.get('classified_by', 'migration')})"
|
||||
)
|
||||
print(f"\nSummary: {from_file} from REPO_CLASSIFICATIONS, {derived} derived")
|
||||
|
||||
|
||||
async def _report_dispositions(session: AsyncSession) -> None:
|
||||
_section("Repo dispositions")
|
||||
if not REPO_DISPOSITIONS:
|
||||
print(" (none)")
|
||||
return
|
||||
for slug, disp in REPO_DISPOSITIONS.items():
|
||||
repo = await session.execute(
|
||||
text("SELECT 1 FROM managed_repos WHERE slug = :slug"),
|
||||
{"slug": slug},
|
||||
)
|
||||
managed = repo.fetchone()
|
||||
state = "found" if managed else "MISSING"
|
||||
print(f" {slug:25} [{state}] action={disp['action']}")
|
||||
if disp.get("target_slug"):
|
||||
print(f" target: {disp['target_slug']}")
|
||||
if disp.get("archive"):
|
||||
print(" would archive phantom/duplicate row")
|
||||
|
||||
|
||||
async def _report_workplan_anchors(session: AsyncSession) -> None:
|
||||
_section("Workplan repo_id backfill (would-be)")
|
||||
rows = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT ws.slug, ws.repo_id, t.slug AS topic_slug, d.slug AS domain_slug,
|
||||
mr.slug AS current_repo
|
||||
FROM workstreams ws
|
||||
LEFT JOIN topics t ON t.id = ws.topic_id
|
||||
LEFT JOIN domains d ON d.id = t.domain_id
|
||||
LEFT JOIN managed_repos mr ON mr.id = ws.repo_id
|
||||
ORDER BY ws.slug
|
||||
"""
|
||||
)
|
||||
)
|
||||
null_count = 0
|
||||
for ws_slug, repo_id, topic_slug, domain_slug, current_repo in rows:
|
||||
if repo_id is None:
|
||||
null_count += 1
|
||||
print(
|
||||
f" NEEDS ANCHOR {ws_slug:40} topic={topic_slug or '-':20} "
|
||||
f"domain={domain_slug or '-'}"
|
||||
)
|
||||
else:
|
||||
print(f" ok {ws_slug:40} repo={current_repo}")
|
||||
print(f"\nWorkstreams with NULL repo_id: {null_count}")
|
||||
if null_count:
|
||||
print(f"Orphans would fall back to: {FALLBACK_REPO_SLUG}")
|
||||
|
||||
|
||||
async def _report_topic_domain_updates(session: AsyncSession) -> None:
|
||||
_section("Topic / domain_goal domain_id remapping")
|
||||
for old_slug, market_slug in OLD_DOMAIN_TO_MARKET.items():
|
||||
topic_count = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*) FROM topics t
|
||||
JOIN domains d ON d.id = t.domain_id
|
||||
WHERE d.slug = :old_slug
|
||||
"""
|
||||
),
|
||||
{"old_slug": old_slug},
|
||||
)
|
||||
goal_count = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*) FROM domain_goals dg
|
||||
JOIN domains d ON d.id = dg.domain_id
|
||||
WHERE d.slug = :old_slug
|
||||
"""
|
||||
),
|
||||
{"old_slug": old_slug},
|
||||
)
|
||||
tc = topic_count.scalar_one()
|
||||
gc = goal_count.scalar_one()
|
||||
if tc or gc:
|
||||
print(f" {old_slug:22} → {market_slug:15} topics={tc} domain_goals={gc}")
|
||||
|
||||
|
||||
async def _report_table_renames(session: AsyncSession) -> None:
|
||||
_section("Schema renames (structural)")
|
||||
fk_tables = [
|
||||
"tasks.workstream_id",
|
||||
"decisions.workstream_id",
|
||||
"progress_events.workstream_id",
|
||||
"token_events.workstream_id",
|
||||
"contributions.related_workstream_id",
|
||||
"extension_points.workstream_id",
|
||||
"technical_debt.workstream_id",
|
||||
"capability_requests.requesting_workstream_id",
|
||||
"capability_requests.fulfilling_workstream_id",
|
||||
"workplan_launch_requests.workstream_id",
|
||||
]
|
||||
for item in fk_tables:
|
||||
print(f" {item} → {item.replace('workstream', 'workplan')}")
|
||||
print(" workstreams → workplans")
|
||||
print(" workstream_dependencies → workplan_dependencies")
|
||||
print(" from_workstream_id → from_workplan_id")
|
||||
print(" to_workstream_id → to_workplan_id")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("STATE-WP-0065 P1 — Spine migration dry-run report")
|
||||
print("(read-only; no changes applied)")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
await _report_domains(session)
|
||||
await _report_classifications(session)
|
||||
await _report_dispositions(session)
|
||||
await _report_workplan_anchors(session)
|
||||
await _report_topic_domain_updates(session)
|
||||
await _report_table_renames(session)
|
||||
|
||||
await engine.dispose()
|
||||
print()
|
||||
print("Dry-run complete. Review the report before running:")
|
||||
print(" alembic upgrade d8e9f0a1b2c3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
67
tests/test_classification.py
Normal file
67
tests/test_classification.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Tests for api.classification validation module (STATE-WP-0065 P2)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from api.classification import (
|
||||
ClassificationData,
|
||||
validate_classification,
|
||||
)
|
||||
|
||||
|
||||
def _valid_block(**overrides) -> dict:
|
||||
base = {
|
||||
"category": "tooling",
|
||||
"domain": "infotech",
|
||||
"secondary_domains": [],
|
||||
"capability_tags": ["platform"],
|
||||
"business_stake": ["technology", "operations"],
|
||||
"business_mechanics": ["coordination"],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestValidateClassification:
|
||||
def test_valid_block_passes(self):
|
||||
errors, warnings = validate_classification(_valid_block())
|
||||
assert errors == []
|
||||
|
||||
def test_missing_category_fails(self):
|
||||
block = _valid_block()
|
||||
del block["category"]
|
||||
errors, _ = validate_classification(block)
|
||||
assert any("category" in err for err in errors)
|
||||
|
||||
def test_invalid_category_fails(self):
|
||||
errors, _ = validate_classification(_valid_block(category="not-a-category"))
|
||||
assert any("category" in err for err in errors)
|
||||
|
||||
def test_invalid_domain_fails(self):
|
||||
errors, _ = validate_classification(_valid_block(domain="not-a-domain"))
|
||||
assert any("domain" in err for err in errors)
|
||||
|
||||
def test_unknown_capability_tag_warns(self):
|
||||
_, warnings = validate_classification(_valid_block(capability_tags=["totally-made-up-tag"]))
|
||||
assert any("capability_tag" in warn for warn in warnings)
|
||||
|
||||
def test_invalid_business_stake_fails(self):
|
||||
errors, _ = validate_classification(_valid_block(business_stake=["not-a-stake"]))
|
||||
assert any("business_stake" in err for err in errors)
|
||||
|
||||
def test_secondary_domain_repeats_primary_fails(self):
|
||||
errors, _ = validate_classification(
|
||||
_valid_block(domain="infotech", secondary_domains=["infotech"])
|
||||
)
|
||||
assert any("repeats the primary domain" in err for err in errors)
|
||||
|
||||
|
||||
class TestClassificationData:
|
||||
def test_round_trip_dict(self):
|
||||
block = _valid_block(classified_at="2026-06-22", classified_by="human", version="1.0")
|
||||
data = ClassificationData.from_block(block)
|
||||
payload = data.to_dict()
|
||||
assert payload["category"] == "tooling"
|
||||
assert payload["domain"] == "infotech"
|
||||
assert payload["classified_by"] == "human"
|
||||
assert payload["standard_version"] == "1.0"
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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]]] = []
|
||||
|
||||
@@ -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"})
|
||||
|
||||
71
tests/test_register_from_classification.py
Normal file
71
tests/test_register_from_classification.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Tests for register_from_classification CLI (STATE-WP-0065 P3)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / "scripts" / "register_from_classification.py"
|
||||
|
||||
|
||||
def test_cli_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "--repo-path" in result.stdout
|
||||
assert "--bulk" in result.stdout
|
||||
assert "--dry-run" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_repo_path_state_hub():
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
from scripts.register_from_classification import run_registration
|
||||
import argparse
|
||||
|
||||
args = argparse.Namespace(
|
||||
repo_path=str(REPO_ROOT),
|
||||
slug=None,
|
||||
bulk=False,
|
||||
dry_run=True,
|
||||
api=False,
|
||||
db=False,
|
||||
api_base="http://127.0.0.1:8000",
|
||||
json=False,
|
||||
)
|
||||
report = await run_registration(args)
|
||||
counts = report.counts()
|
||||
assert counts["invalid"] == 0
|
||||
assert counts["registered"] + counts["updated"] + counts["skipped"] >= 1
|
||||
assert any(r.slug == "state-hub" for r in report.results)
|
||||
# Valid classification file is always parsed even when DB domains are absent.
|
||||
assert not any("repo_classification block" in r.detail for r in report.results)
|
||||
|
||||
|
||||
def test_json_report_shape():
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--repo-path",
|
||||
str(REPO_ROOT),
|
||||
"--dry-run",
|
||||
"--json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["summary"]["invalid"] == 0
|
||||
assert "summary" in payload
|
||||
assert "results" in payload
|
||||
assert set(payload["summary"]) == {"registered", "updated", "skipped", "invalid"}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user