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,8 +1,15 @@
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
<!-- TODO: Describe the key design decisions and component structure.
|
State Hub uses a **repo-anchored classification spine** (STATE-WP-0065):
|
||||||
Key modules, data flows, external integrations, state machines, etc. -->
|
|
||||||
|
- **Primary anchor:** `managed_repos` + committed `.repo-classification.yaml`
|
||||||
|
- **Market domain:** derived from classification (`domain` field) — 14 fixed values
|
||||||
|
- **Workplans:** table `workplans`, `repo_id` required, `topic_id` optional
|
||||||
|
- **Legacy:** `/workstreams/` REST routes and `workstream_*` MCP tools are aliases
|
||||||
|
|
||||||
|
Classification canon lives in `the-custodian/canon/standards/`.
|
||||||
|
Validation: `api/classification.py` · registration: `make register-from-classification`.
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
**Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs.
|
**Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs.
|
||||||
|
|
||||||
**Domain:** custodian
|
**Classification:** tooling · infotech (see `.repo-classification.yaml`)
|
||||||
**Repo slug:** state-hub
|
**Repo slug:** state-hub
|
||||||
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a *(legacy optional tag)*
|
||||||
|
|
||||||
|
Coordination spine is **repo-anchored** — workplans bind to `repo_id`, market
|
||||||
|
domain is derived from classification, not from the old coordination-domain model.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ For each file with `status: ready`, `active`, or `blocked`, note pending
|
|||||||
|
|
||||||
**Step 4 — Present brief**
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
1. **Active workstreams** for `custodian` — title, task counts, blocking decisions
|
1. **Active workplans** for this repo — title, task counts, blocking decisions
|
||||||
2. **Pending tasks** from `workplans/` + any `[repo:state-hub]` hub tasks
|
2. **Pending tasks** from `workplans/` + any `[repo:state-hub]` hub tasks
|
||||||
3. **Goal guidance** — if `goal_guidance` in summary:
|
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||||
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||||
@@ -51,12 +51,13 @@ For each file with `status: ready`, `active`, or `blocked`, note pending
|
|||||||
4. **Suggested next action** — highest-priority open item
|
4. **Suggested next action** — highest-priority open item
|
||||||
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||||
|
|
||||||
If no workstreams: follow First Session Protocol (`first-session.md`).
|
If no workplans: follow First Session Protocol (`first-session.md`).
|
||||||
|
|
||||||
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||||
|
|
||||||
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
> State Hub is a *read model*. Bootstrap tools (`create_workplan`, `create_task`)
|
||||||
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||||
|
> Repo registration uses `.repo-classification.yaml` via `register_repo_from_classification`.
|
||||||
|
|
||||||
**Session close:**
|
**Session close:**
|
||||||
With MCP tools:
|
With MCP tools:
|
||||||
|
|||||||
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
|
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
@@ -322,5 +322,20 @@ remove-hooks:
|
|||||||
gitea-inventory:
|
gitea-inventory:
|
||||||
$(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json)
|
$(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json)
|
||||||
|
|
||||||
|
## Register/update one repo from .repo-classification.yaml:
|
||||||
|
## make register-from-classification REPO=state-hub
|
||||||
|
## make register-from-classification PATH=/path/to/repo
|
||||||
|
## Optional: DRY_RUN=1
|
||||||
|
register-from-classification:
|
||||||
|
@test -n "$(REPO)" -o -n "$(PATH)" || (echo "ERROR: REPO or PATH is required."; exit 1)
|
||||||
|
$(UV) run python scripts/register_from_classification.py \
|
||||||
|
$(if $(PATH),--repo-path "$(PATH)",--slug "$(REPO)") \
|
||||||
|
$(if $(DRY_RUN),--dry-run,)
|
||||||
|
|
||||||
|
## Bulk register/update all active repos with accessible local paths
|
||||||
|
register-from-classification-all:
|
||||||
|
$(UV) run python scripts/register_from_classification.py --bulk \
|
||||||
|
$(if $(DRY_RUN),--dry-run,)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
$(COMPOSE) down -v
|
$(COMPOSE) down -v
|
||||||
|
|||||||
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` | Start postgres container |
|
||||||
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
|
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
|
||||||
| `make migrate` | `alembic upgrade head` |
|
| `make migrate` | `alembic upgrade head` |
|
||||||
| `make seed` | Insert 6 canonical topics |
|
| `make seed` | Insert 6 canonical topics (legacy bootstrap) |
|
||||||
|
| `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` |
|
||||||
|
| `make register-from-classification-all` | Bulk reclassify all repos with classification files |
|
||||||
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
|
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
|
||||||
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
|
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
|
||||||
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
|
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
|
||||||
@@ -125,28 +127,30 @@ custodian register-project # register cwd as a Custodian project
|
|||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
Five tables in dependency order:
|
Repo-anchored coordination spine (STATE-WP-0065):
|
||||||
|
|
||||||
```
|
```
|
||||||
topics
|
domains (14 market domains: infotech, financials, communication, …)
|
||||||
└── workstreams
|
managed_repos (classification: category, domain, capability_tags, business_stake, …)
|
||||||
└── tasks (self-FK: parent_task_id)
|
└── workplans (repo_id required; topic_id optional legacy tag)
|
||||||
|
└── tasks
|
||||||
└── progress_events
|
└── progress_events
|
||||||
decisions (FK: topic_id, workstream_id — at least one required)
|
topics (optional cross-repo tag; domain_id → market domain)
|
||||||
└── progress_events
|
decisions (FK: topic_id and/or workplan_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Enums
|
Each registered repo carries a committed `.repo-classification.yaml` (canon
|
||||||
|
standard v1.0). Registration and reclassification use
|
||||||
|
`make register-from-classification`.
|
||||||
|
|
||||||
| Enum | Values |
|
### Key enums / vocabularies
|
||||||
|
|
||||||
|
| Field | Values |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `topic_status` | `active` · `paused` · `archived` |
|
| `workplan_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||||
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
|
||||||
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
|
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
|
||||||
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
| `repo category` | `experimental` · `research` · `project` · `tooling` · `product` · `business` |
|
||||||
| `decision_type` | `made` · `pending` |
|
| `market domain` | 14 fixed slugs — see `the-custodian/canon/standards/repo-classification.allowed.yaml` |
|
||||||
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
|
|
||||||
| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` |
|
|
||||||
|
|
||||||
### Governance constraints encoded in schema
|
### Governance constraints encoded in schema
|
||||||
|
|
||||||
@@ -226,9 +230,11 @@ See `mcp_server/TOOLS.md` for the full tool reference card (30 lines, faster tha
|
|||||||
|
|
||||||
**Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress`
|
**Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress`
|
||||||
|
|
||||||
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `update_workstream_status`
|
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `create_workplan` · `update_workplan_status` · `register_repo_from_classification`
|
||||||
|
|
||||||
**Resources**: `state://summary` · `state://topics` · `state://workstreams/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
|
**Resources**: `state://summary` · `state://topics` · `state://workplans/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
|
||||||
|
|
||||||
|
Legacy `workstream_*` tool names remain as aliases — see `mcp_server/TOOLS.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
9
SCOPE.md
9
SCOPE.md
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## One-Liner
|
## One-Liner
|
||||||
|
|
||||||
State Hub is the local-first coordination service for Custodian workstreams,
|
State Hub is the local-first coordination service for repo-anchored workplans,
|
||||||
tasks, decisions, progress events, repo metadata, MCP tooling, and dashboard
|
tasks, decisions, progress events, repo classification and metadata, MCP
|
||||||
telemetry.
|
tooling, and dashboard telemetry.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
@@ -12,7 +12,8 @@ telemetry.
|
|||||||
- PostgreSQL schema and Alembic migrations
|
- PostgreSQL schema and Alembic migrations
|
||||||
- FastMCP server and tool reference
|
- FastMCP server and tool reference
|
||||||
- Observable dashboard
|
- Observable dashboard
|
||||||
- repo registration and consistency synchronization
|
- repo registration (classification-driven) and consistency synchronization
|
||||||
|
- repo classification spine (14 market domains, `.repo-classification.yaml`)
|
||||||
- task-flow engine and flow definitions
|
- task-flow engine and flow definitions
|
||||||
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
|
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
|
||||||
- State Hub tests, operational docs, policies, prompts, and local infra
|
- State Hub tests, operational docs, policies, prompts, and local infra
|
||||||
|
|||||||
290
api/classification.py
Normal file
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.topic import Topic, TopicStatus
|
||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
||||||
|
from api.models.workplan import Workplan
|
||||||
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.models.workstream import Workstream
|
from api.models.workstream import Workstream
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workstream_dependency import WorkstreamDependency
|
||||||
from api.models.task import Task, TaskStatus, TaskPriority
|
from api.models.task import Task, TaskStatus, TaskPriority
|
||||||
@@ -39,6 +41,8 @@ __all__ = [
|
|||||||
"Topic", "TopicStatus",
|
"Topic", "TopicStatus",
|
||||||
"ManagedRepo",
|
"ManagedRepo",
|
||||||
"RepoGoal", "RepoGoalStatus",
|
"RepoGoal", "RepoGoalStatus",
|
||||||
|
"Workplan",
|
||||||
|
"WorkplanDependency",
|
||||||
"Workstream",
|
"Workstream",
|
||||||
"WorkstreamDependency",
|
"WorkstreamDependency",
|
||||||
"Task", "TaskStatus", "TaskPriority",
|
"Task", "TaskStatus", "TaskPriority",
|
||||||
@@ -61,4 +65,4 @@ __all__ = [
|
|||||||
"WorkplanLaunchRequest",
|
"WorkplanLaunchRequest",
|
||||||
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
|
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
|
||||||
"LegacyInterface", "LegacyInterfaceUsageBucket",
|
"LegacyInterface", "LegacyInterfaceUsageBucket",
|
||||||
]
|
]
|
||||||
@@ -31,9 +31,9 @@ class CapabilityRequest(Base, TimestampMixin):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
@@ -45,9 +45,9 @@ class CapabilityRequest(Base, TimestampMixin):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
fulfilling_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ class Contribution(Base, TimestampMixin):
|
|||||||
related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
related_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
related_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
|
||||||
@@ -62,5 +62,5 @@ class Contribution(Base, TimestampMixin):
|
|||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ class Decision(Base, TimestampMixin):
|
|||||||
__tablename__ = "decisions"
|
__tablename__ = "decisions"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
|
"topic_id IS NOT NULL OR workplan_id IS NOT NULL",
|
||||||
name="ck_decisions_topic_or_workstream",
|
name="ck_decisions_topic_or_workplan",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ class Decision(Base, TimestampMixin):
|
|||||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -57,7 +57,7 @@ class Decision(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
|
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
|
||||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821
|
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="decisions") # noqa: F821
|
||||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||||
"ProgressEvent", back_populates="decision", lazy="selectin"
|
"ProgressEvent", back_populates="decision", lazy="selectin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ class ExtensionPoint(Base, TimestampMixin):
|
|||||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def domain_slug(self) -> str:
|
def domain_slug(self) -> str:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
from sqlalchemy import Date, DateTime, ForeignKey, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from api.models.base import Base, TimestampMixin, new_uuid
|
from api.models.base import Base, TimestampMixin, new_uuid
|
||||||
@@ -36,6 +36,15 @@ class ManagedRepo(Base, TimestampMixin):
|
|||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
category: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
secondary_domains: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
|
capability_tags: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
|
business_stake: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
|
business_mechanics: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
|
classified_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
classified_by: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
standard_version: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
|
||||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||||
"Domain", back_populates="repos", lazy="selectin"
|
"Domain", back_populates="repos", lazy="selectin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ class ProgressEvent(Base):
|
|||||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
@@ -38,6 +38,6 @@ class ProgressEvent(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
|
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
|
||||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821
|
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="progress_events") # noqa: F821
|
||||||
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
|
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
|
||||||
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821
|
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ class RepoGoal(Base, TimestampMixin):
|
|||||||
domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
|
domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
|
||||||
"DomainGoal", back_populates="repo_goals", lazy="selectin"
|
"DomainGoal", back_populates="repo_goals", lazy="selectin"
|
||||||
)
|
)
|
||||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||||
"Workstream", back_populates="repo_goal", lazy="selectin"
|
"Workplan", back_populates="repo_goal", lazy="selectin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class Task(Base, TimestampMixin):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -50,7 +50,7 @@ class Task(Base, TimestampMixin):
|
|||||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821
|
||||||
subtasks: Mapped[list["Task"]] = relationship(
|
subtasks: Mapped[list["Task"]] = relationship(
|
||||||
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ class TechnicalDebt(Base, TimestampMixin):
|
|||||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||||
notes: Mapped[list["TDNote"]] = relationship(
|
notes: Mapped[list["TDNote"]] = relationship(
|
||||||
"TDNote", back_populates="td", lazy="selectin",
|
"TDNote", back_populates="td", lazy="selectin",
|
||||||
order_by="TDNote.created_at",
|
order_by="TDNote.created_at",
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ class TokenEvent(Base):
|
|||||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
@@ -75,5 +75,5 @@ class TokenEvent(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||||
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ class Topic(Base, TimestampMixin):
|
|||||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||||
"Domain", back_populates="topics", lazy="selectin"
|
"Domain", back_populates="topics", lazy="selectin"
|
||||||
)
|
)
|
||||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||||
"Workstream", back_populates="topic", lazy="selectin"
|
"Workplan", back_populates="topic", lazy="selectin"
|
||||||
)
|
)
|
||||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||||
"Decision", back_populates="topic", lazy="selectin"
|
"Decision", back_populates="topic", lazy="selectin"
|
||||||
|
|||||||
70
api/models/workplan.py
Normal file
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(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
@@ -36,4 +36,4 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
|
|||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||||
|
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="launch_requests") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="launch_requests") # noqa: F821
|
||||||
|
|||||||
@@ -1,70 +1,6 @@
|
|||||||
import uuid
|
"""Backward-compatibility shim — prefer ``api.models.workplan``."""
|
||||||
from datetime import date, datetime
|
from api.models.workplan import Workplan
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
|
Workstream = Workplan
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from api.models.base import Base, TimestampMixin, new_uuid
|
__all__ = ["Workstream", "Workplan"]
|
||||||
|
|
||||||
|
|
||||||
class Workstream(Base, TimestampMixin):
|
|
||||||
__tablename__ = "workstreams"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
|
||||||
)
|
|
||||||
topic_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="active", server_default="active"
|
|
||||||
)
|
|
||||||
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
||||||
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
|
||||||
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
|
||||||
execution_state: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
|
||||||
)
|
|
||||||
launch_mode: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
|
||||||
)
|
|
||||||
concurrency_mode: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="sequential", server_default="sequential", index=True
|
|
||||||
)
|
|
||||||
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
|
||||||
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
|
||||||
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
|
||||||
|
|
||||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("managed_repos.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("repo_goals.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821
|
|
||||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
|
||||||
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workstreams", lazy="selectin") # noqa: F821
|
|
||||||
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
|
|
||||||
"Task", back_populates="workstream", lazy="selectin"
|
|
||||||
)
|
|
||||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
|
||||||
"Decision", back_populates="workstream", lazy="selectin"
|
|
||||||
)
|
|
||||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
|
||||||
"ProgressEvent", back_populates="workstream", lazy="selectin"
|
|
||||||
)
|
|
||||||
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
|
|
||||||
"WorkplanLaunchRequest", back_populates="workstream", lazy="selectin"
|
|
||||||
)
|
|
||||||
@@ -1,75 +1,6 @@
|
|||||||
import uuid
|
"""Backward-compatibility shim — prefer ``api.models.workplan_dependency``."""
|
||||||
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
|
|
||||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
|
WorkstreamDependency = WorkplanDependency
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from api.models.base import Base, TimestampMixin, new_uuid
|
__all__ = ["WorkstreamDependency", "WorkplanDependency"]
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamDependency(Base, TimestampMixin):
|
|
||||||
"""Directed dependency edge: `from_workstream` depends on a workstream or task.
|
|
||||||
|
|
||||||
Semantics: the target must reach a satisfactory state before `from_workstream`
|
|
||||||
can fully proceed. Hard deletes are intentional —
|
|
||||||
removing an edge removes a constraint, not information.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "workstream_dependencies"
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint(
|
|
||||||
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
|
|
||||||
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
|
|
||||||
name="ck_ws_dep_exactly_one_target",
|
|
||||||
),
|
|
||||||
Index(
|
|
||||||
"uq_ws_dep_workstream_target",
|
|
||||||
"from_workstream_id",
|
|
||||||
"to_workstream_id",
|
|
||||||
"relationship_type",
|
|
||||||
unique=True,
|
|
||||||
postgresql_where=text("to_workstream_id IS NOT NULL"),
|
|
||||||
),
|
|
||||||
Index(
|
|
||||||
"uq_ws_dep_task_target",
|
|
||||||
"from_workstream_id",
|
|
||||||
"to_task_id",
|
|
||||||
"relationship_type",
|
|
||||||
unique=True,
|
|
||||||
postgresql_where=text("to_task_id IS NOT NULL"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
|
||||||
)
|
|
||||||
from_workstream_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
to_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("tasks.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
relationship_type: Mapped[str] = mapped_column(
|
|
||||||
String(40), nullable=False, default="blocks", server_default="blocks", index=True
|
|
||||||
)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
from_workstream: Mapped["Workstream"] = relationship( # noqa: F821
|
|
||||||
"Workstream", foreign_keys=[from_workstream_id]
|
|
||||||
)
|
|
||||||
to_workstream: Mapped["Workstream | None"] = relationship( # noqa: F821
|
|
||||||
"Workstream", foreign_keys=[to_workstream_id]
|
|
||||||
)
|
|
||||||
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
|
|
||||||
@@ -68,7 +68,7 @@ async def create_request(
|
|||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
requesting_domain_id=req_domain.id,
|
requesting_domain_id=req_domain.id,
|
||||||
requesting_agent=body.requesting_agent,
|
requesting_agent=body.requesting_agent,
|
||||||
requesting_workstream_id=body.requesting_workstream_id,
|
requesting_workplan_id=body.requesting_workplan_id,
|
||||||
blocking_task_id=body.blocking_task_id,
|
blocking_task_id=body.blocking_task_id,
|
||||||
fulfilling_domain_id=fulfilling_domain_id,
|
fulfilling_domain_id=fulfilling_domain_id,
|
||||||
catalog_entry_id=catalog_entry_id,
|
catalog_entry_id=catalog_entry_id,
|
||||||
@@ -115,7 +115,7 @@ async def accept_request(
|
|||||||
now = datetime.now(tz=timezone.utc)
|
now = datetime.now(tz=timezone.utc)
|
||||||
req.status = "accepted"
|
req.status = "accepted"
|
||||||
req.fulfilling_agent = body.fulfilling_agent
|
req.fulfilling_agent = body.fulfilling_agent
|
||||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||||
req.accepted_at = now
|
req.accepted_at = now
|
||||||
|
|
||||||
# If no fulfilling domain was set by routing, infer from the accepting agent's context
|
# If no fulfilling domain was set by routing, infer from the accepting agent's context
|
||||||
@@ -212,7 +212,7 @@ async def patch_request(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> CapabilityRequest:
|
) -> CapabilityRequest:
|
||||||
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
|
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
|
||||||
priority, blocking_task_id, fulfilling_workstream_id.
|
priority, blocking_task_id, fulfilling_workplan_id.
|
||||||
Only fields present in the request body (non-None) are updated.
|
Only fields present in the request body (non-None) are updated.
|
||||||
"""
|
"""
|
||||||
req = await _get_request_or_404(request_id, session)
|
req = await _get_request_or_404(request_id, session)
|
||||||
@@ -241,9 +241,9 @@ async def patch_request(
|
|||||||
req.blocking_task_id = body.blocking_task_id
|
req.blocking_task_id = body.blocking_task_id
|
||||||
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
|
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
|
||||||
|
|
||||||
if body.fulfilling_workstream_id is not None:
|
if body.fulfilling_workplan_id is not None:
|
||||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||||
corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}")
|
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
|
||||||
|
|
||||||
if not corrections:
|
if not corrections:
|
||||||
return req # no-op
|
return req # no-op
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async def create_contribution(
|
|||||||
title=body.title,
|
title=body.title,
|
||||||
body_path=body.body_path,
|
body_path=body.body_path,
|
||||||
related_topic_id=body.related_topic_id,
|
related_topic_id=body.related_topic_id,
|
||||||
related_workstream_id=body.related_workstream_id,
|
related_workplan_id=body.related_workplan_id,
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
status=ContributionStatus.draft,
|
status=ContributionStatus.draft,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ def _needs_escalation(body: DecisionCreate) -> str | None:
|
|||||||
@router.get("/", response_model=list[DecisionRead])
|
@router.get("/", response_model=list[DecisionRead])
|
||||||
async def list_decisions(
|
async def list_decisions(
|
||||||
topic_id: uuid.UUID | None = None,
|
topic_id: uuid.UUID | None = None,
|
||||||
|
workplan_id: uuid.UUID | None = None,
|
||||||
workstream_id: uuid.UUID | None = None,
|
workstream_id: uuid.UUID | None = None,
|
||||||
status: DecisionStatus | None = None,
|
status: DecisionStatus | None = None,
|
||||||
decision_type: DecisionType | None = None,
|
decision_type: DecisionType | None = None,
|
||||||
@@ -48,8 +49,9 @@ async def list_decisions(
|
|||||||
q = select(Decision)
|
q = select(Decision)
|
||||||
if topic_id:
|
if topic_id:
|
||||||
q = q.where(Decision.topic_id == topic_id)
|
q = q.where(Decision.topic_id == topic_id)
|
||||||
if workstream_id:
|
scope_id = workplan_id or workstream_id
|
||||||
q = q.where(Decision.workstream_id == workstream_id)
|
if scope_id:
|
||||||
|
q = q.where(Decision.workplan_id == scope_id)
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Decision.status == status)
|
q = q.where(Decision.status == status)
|
||||||
if decision_type:
|
if decision_type:
|
||||||
@@ -139,7 +141,7 @@ async def resolve_decision_action(
|
|||||||
|
|
||||||
event = ProgressEvent(
|
event = ProgressEvent(
|
||||||
topic_id=decision.topic_id,
|
topic_id=decision.topic_id,
|
||||||
workstream_id=decision.workstream_id,
|
workplan_id=decision.workplan_id,
|
||||||
decision_id=decision.id,
|
decision_id=decision.id,
|
||||||
event_type="decision_resolved",
|
event_type="decision_resolved",
|
||||||
summary=f"Decision resolved: {decision.title}",
|
summary=f"Decision resolved: {decision.title}",
|
||||||
@@ -159,7 +161,7 @@ async def resolve_decision_action(
|
|||||||
"decision_id": str(decision.id),
|
"decision_id": str(decision.id),
|
||||||
"title": decision.title,
|
"title": decision.title,
|
||||||
"topic_id": str(decision.topic_id) if decision.topic_id else None,
|
"topic_id": str(decision.topic_id) if decision.topic_id else None,
|
||||||
"workstream_id": str(decision.workstream_id) if decision.workstream_id else None,
|
"workstream_id": str(decision.workplan_id) if decision.workplan_id else None,
|
||||||
"decided_by": body.decided_by,
|
"decided_by": body.decided_by,
|
||||||
"rationale_snippet": (body.rationale or "")[:240],
|
"rationale_snippet": (body.rationale or "")[:240],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from api.models.extension_point import ExtensionPoint
|
|||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.technical_debt import TechnicalDebt
|
from api.models.technical_debt import TechnicalDebt
|
||||||
from api.models.topic import Topic
|
from api.models.topic import Topic
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.domain import (
|
from api.schemas.domain import (
|
||||||
DomainCreate,
|
DomainCreate,
|
||||||
DomainDetail,
|
DomainDetail,
|
||||||
@@ -32,9 +32,9 @@ async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainD
|
|||||||
workstream_count = 0
|
workstream_count = 0
|
||||||
if topic_ids:
|
if topic_ids:
|
||||||
workstream_count_row = await session.execute(
|
workstream_count_row = await session.execute(
|
||||||
select(func.count()).select_from(Workstream)
|
select(func.count()).select_from(Workplan)
|
||||||
.where(Workstream.topic_id.in_(topic_ids))
|
.where(Workplan.topic_id.in_(topic_ids))
|
||||||
.where(Workstream.status == "active")
|
.where(Workplan.status == "active")
|
||||||
)
|
)
|
||||||
workstream_count = workstream_count_row.scalar_one()
|
workstream_count = workstream_count_row.scalar_one()
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
from api.models.task import Task, TaskStatus
|
from api.models.task import Task, TaskStatus
|
||||||
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.schemas.execution import (
|
from api.schemas.execution import (
|
||||||
ExecutionIntentRead,
|
ExecutionIntentRead,
|
||||||
ExecutionIntentUpdate,
|
ExecutionIntentUpdate,
|
||||||
@@ -25,10 +25,10 @@ from api.services.execution_queue import (
|
|||||||
STATE_HUB_RESPONSIBILITIES,
|
STATE_HUB_RESPONSIBILITIES,
|
||||||
execution_state_for_launch,
|
execution_state_for_launch,
|
||||||
queue_sort_key,
|
queue_sort_key,
|
||||||
workstream_blockers,
|
workplan_blockers,
|
||||||
)
|
)
|
||||||
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
||||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/execution", tags=["execution"])
|
router = APIRouter(prefix="/execution", tags=["execution"])
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ async def _update_execution_intent(
|
|||||||
body: ExecutionIntentUpdate,
|
body: ExecutionIntentUpdate,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> ExecutionIntentRead:
|
) -> ExecutionIntentRead:
|
||||||
ws = await session.get(Workstream, workstream_id)
|
ws = await session.get(Workplan, workstream_id)
|
||||||
if ws is None:
|
if ws is None:
|
||||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
|
|
||||||
@@ -94,22 +94,22 @@ async def workplan_stack(
|
|||||||
include_blocked: bool = Query(True),
|
include_blocked: bool = Query(True),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[WorkplanQueueItem]:
|
) -> list[WorkplanQueueItem]:
|
||||||
result = await session.execute(select(Workstream))
|
result = await session.execute(select(Workplan))
|
||||||
workstreams = [
|
workstreams = [
|
||||||
ws for ws in result.scalars().all()
|
ws for ws in result.scalars().all()
|
||||||
if normalize_workstream_status(ws.status) not in CLOSED_WORKSTREAM_STATUSES
|
if normalize_workplan_status(ws.status) not in CLOSED_WORKPLAN_STATUSES
|
||||||
]
|
]
|
||||||
ws_by_id = {ws.id: ws for ws in workstreams}
|
ws_by_id = {ws.id: ws for ws in workstreams}
|
||||||
ws_status = {ws.id: normalize_workstream_status(ws.status) for ws in workstreams}
|
ws_status = {ws.id: normalize_workplan_status(ws.status) for ws in workstreams}
|
||||||
|
|
||||||
dep_result = await session.execute(select(WorkstreamDependency))
|
dep_result = await session.execute(select(WorkplanDependency))
|
||||||
ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||||
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||||
for dep in dep_result.scalars().all():
|
for dep in dep_result.scalars().all():
|
||||||
if dep.to_workstream_id is not None:
|
if dep.to_workplan_id is not None:
|
||||||
ws_deps.setdefault(dep.from_workstream_id, []).append(dep.to_workstream_id)
|
ws_deps.setdefault(dep.from_workplan_id, []).append(dep.to_workplan_id)
|
||||||
if dep.to_task_id is not None:
|
if dep.to_task_id is not None:
|
||||||
task_deps.setdefault(dep.from_workstream_id, []).append(dep.to_task_id)
|
task_deps.setdefault(dep.from_workplan_id, []).append(dep.to_task_id)
|
||||||
|
|
||||||
task_ids = [task_id for ids in task_deps.values() for task_id in ids]
|
task_ids = [task_id for ids in task_deps.values() for task_id in ids]
|
||||||
task_status: dict[uuid.UUID, str] = {}
|
task_status: dict[uuid.UUID, str] = {}
|
||||||
@@ -121,9 +121,9 @@ async def workplan_stack(
|
|||||||
for ws in workstreams:
|
for ws in workstreams:
|
||||||
if not include_manual and ws.execution_state == "manual":
|
if not include_manual and ws.execution_state == "manual":
|
||||||
continue
|
continue
|
||||||
lifecycle_status = normalize_workstream_status(ws.status)
|
lifecycle_status = normalize_workplan_status(ws.status)
|
||||||
blocked_ws = [
|
blocked_ws = [
|
||||||
blocker for blocker in workstream_blockers(ws.id, ws_deps, ws_status)
|
blocker for blocker in workplan_blockers(ws.id, ws_deps, ws_status)
|
||||||
if blocker in ws_by_id or blocker in ws_status
|
if blocker in ws_by_id or blocker in ws_status
|
||||||
]
|
]
|
||||||
blocked_tasks = [
|
blocked_tasks = [
|
||||||
@@ -135,7 +135,7 @@ async def workplan_stack(
|
|||||||
continue
|
continue
|
||||||
sort_key = queue_sort_key(ws, eligible=eligible)
|
sort_key = queue_sort_key(ws, eligible=eligible)
|
||||||
items.append(WorkplanQueueItem(
|
items.append(WorkplanQueueItem(
|
||||||
workstream_id=ws.id,
|
workplan_id=ws.id,
|
||||||
slug=ws.slug,
|
slug=ws.slug,
|
||||||
title=ws.title,
|
title=ws.title,
|
||||||
status=lifecycle_status,
|
status=lifecycle_status,
|
||||||
@@ -149,7 +149,7 @@ async def workplan_stack(
|
|||||||
execution_group=ws.execution_group,
|
execution_group=ws.execution_group,
|
||||||
scheduled_for=ws.scheduled_for,
|
scheduled_for=ws.scheduled_for,
|
||||||
eligible=eligible,
|
eligible=eligible,
|
||||||
blocked_by_workstream_ids=blocked_ws,
|
blocked_by_workplan_ids=blocked_ws,
|
||||||
blocked_by_task_ids=blocked_tasks,
|
blocked_by_task_ids=blocked_tasks,
|
||||||
sort_key=sort_key,
|
sort_key=sort_key,
|
||||||
))
|
))
|
||||||
@@ -165,12 +165,12 @@ async def create_launch_request(
|
|||||||
body: LaunchRequestCreate,
|
body: LaunchRequestCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WorkplanLaunchRequest:
|
) -> WorkplanLaunchRequest:
|
||||||
ws = await session.get(Workstream, body.workstream_id)
|
ws = await session.get(Workplan, body.workplan_id)
|
||||||
if ws is None:
|
if ws is None:
|
||||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
|
|
||||||
launch_request = WorkplanLaunchRequest(
|
launch_request = WorkplanLaunchRequest(
|
||||||
workstream_id=ws.id,
|
workplan_id=ws.id,
|
||||||
requested_by=body.requested_by,
|
requested_by=body.requested_by,
|
||||||
requested_actor=body.requested_actor,
|
requested_actor=body.requested_actor,
|
||||||
launch_mode=body.launch_mode,
|
launch_mode=body.launch_mode,
|
||||||
@@ -199,16 +199,16 @@ async def list_launch_requests(
|
|||||||
) -> list[WorkplanLaunchRequest]:
|
) -> list[WorkplanLaunchRequest]:
|
||||||
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
|
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
|
||||||
if workstream_id:
|
if workstream_id:
|
||||||
q = q.where(WorkplanLaunchRequest.workstream_id == workstream_id)
|
q = q.where(WorkplanLaunchRequest.workplan_id == workstream_id)
|
||||||
if request_status:
|
if request_status:
|
||||||
q = q.where(WorkplanLaunchRequest.status == request_status)
|
q = q.where(WorkplanLaunchRequest.status == request_status)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
def _intent_read(ws: Workstream) -> ExecutionIntentRead:
|
def _intent_read(ws: Workplan) -> ExecutionIntentRead:
|
||||||
return ExecutionIntentRead(
|
return ExecutionIntentRead(
|
||||||
workstream_id=ws.id,
|
workplan_id=ws.id,
|
||||||
execution_state=ws.execution_state,
|
execution_state=ws.execution_state,
|
||||||
launch_mode=ws.launch_mode,
|
launch_mode=ws.launch_mode,
|
||||||
concurrency_mode=ws.concurrency_mode,
|
concurrency_mode=ws.concurrency_mode,
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ from api.flow_defs import (
|
|||||||
from api.models.capability_request import CapabilityRequest
|
from api.models.capability_request import CapabilityRequest
|
||||||
from api.models.contribution import Contribution
|
from api.models.contribution import Contribution
|
||||||
from api.models.task import Task, TaskStatus
|
from api.models.task import Task, TaskStatus
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.services.lifecycle import transition_task_status, transition_workstream_status
|
from api.services.lifecycle import transition_task_status, transition_workplan_status
|
||||||
from api.workplan_status import normalize_workstream_status
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/flows", tags=["flows"])
|
router = APIRouter(prefix="/flows", tags=["flows"])
|
||||||
|
|
||||||
@@ -94,9 +94,9 @@ async def advance_workstation(
|
|||||||
|
|
||||||
entity = await _entity(entity_type, entity_id, session)
|
entity = await _entity(entity_type, entity_id, session)
|
||||||
if entity_type == "workstream":
|
if entity_type == "workstream":
|
||||||
transition_workstream_status(entity, target_workstation)
|
transition_workplan_status(entity, target_workstation)
|
||||||
elif entity_type == "task":
|
elif entity_type == "task":
|
||||||
parent = await session.get(Workstream, entity.workstream_id)
|
parent = await session.get(Workplan, entity.workplan_id)
|
||||||
transition_task_status(
|
transition_task_status(
|
||||||
entity,
|
entity,
|
||||||
target_workstation,
|
target_workstation,
|
||||||
@@ -117,7 +117,7 @@ async def _flow_object(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
entity = await _entity(entity_type, entity_id, session)
|
entity = await _entity(entity_type, entity_id, session)
|
||||||
status = _value(entity.status)
|
status = _value(entity.status)
|
||||||
current_status = normalize_workstream_status(status) if entity_type == "workstream" else status
|
current_status = normalize_workplan_status(status) if entity_type == "workstream" else status
|
||||||
obj: dict[str, Any] = {
|
obj: dict[str, Any] = {
|
||||||
"id": str(entity.id),
|
"id": str(entity.id),
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
@@ -127,21 +127,21 @@ async def _flow_object(
|
|||||||
|
|
||||||
if entity_type == "workstream":
|
if entity_type == "workstream":
|
||||||
tasks = list((await session.execute(
|
tasks = list((await session.execute(
|
||||||
select(Task).where(Task.workstream_id == entity_id)
|
select(Task).where(Task.workplan_id == entity_id)
|
||||||
)).scalars().all())
|
)).scalars().all())
|
||||||
deps = list((await session.execute(
|
deps = list((await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
WorkstreamDependency.from_workstream_id == entity_id
|
WorkplanDependency.from_workplan_id == entity_id
|
||||||
)
|
)
|
||||||
)).scalars().all())
|
)).scalars().all())
|
||||||
dependency_ids = [dep.to_workstream_id for dep in deps]
|
dependency_ids = [dep.to_workplan_id for dep in deps]
|
||||||
dependency_workstations: list[dict[str, Any]] = []
|
dependency_workstations: list[dict[str, Any]] = []
|
||||||
if dependency_ids:
|
if dependency_ids:
|
||||||
dep_ws = list((await session.execute(
|
dep_ws = list((await session.execute(
|
||||||
select(Workstream).where(Workstream.id.in_(dependency_ids))
|
select(Workplan).where(Workplan.id.in_(dependency_ids))
|
||||||
)).scalars().all())
|
)).scalars().all())
|
||||||
dependency_workstations = [
|
dependency_workstations = [
|
||||||
{"id": str(ws.id), "workstation": normalize_workstream_status(ws.status)}
|
{"id": str(ws.id), "workstation": normalize_workplan_status(ws.status)}
|
||||||
for ws in dep_ws
|
for ws in dep_ws
|
||||||
]
|
]
|
||||||
obj.update({
|
obj.update({
|
||||||
@@ -163,7 +163,7 @@ async def _entity(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
):
|
):
|
||||||
model_by_type = {
|
model_by_type = {
|
||||||
"workstream": Workstream,
|
"workstream": Workplan,
|
||||||
"task": Task,
|
"task": Task,
|
||||||
"contribution": Contribution,
|
"contribution": Contribution,
|
||||||
"capability_request": CapabilityRequest,
|
"capability_request": CapabilityRequest,
|
||||||
|
|||||||
@@ -9,23 +9,23 @@ from api.models.agent_message import AgentMessage
|
|||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.task import TaskStatus
|
from api.models.task import TaskStatus
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
|
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
|
||||||
from api.services.lifecycle import (
|
from api.services.lifecycle import (
|
||||||
should_activate_parent_for_task_start,
|
should_activate_parent_for_task_start,
|
||||||
status_value,
|
status_value,
|
||||||
transition_task_status,
|
transition_task_status,
|
||||||
transition_workstream_status,
|
transition_workplan_status,
|
||||||
)
|
)
|
||||||
from api.task_status import TERMINAL_TASK_STATUSES
|
from api.task_status import TERMINAL_TASK_STATUSES
|
||||||
from api.services.reconciliation import (
|
from api.services.reconciliation import (
|
||||||
ReconciliationClass,
|
ReconciliationClass,
|
||||||
StateChangeClassification,
|
StateChangeClassification,
|
||||||
classify_task_status_change,
|
classify_task_status_change,
|
||||||
classify_workstream_status_change,
|
classify_workplan_status_change,
|
||||||
)
|
)
|
||||||
from api.services.workplan_files import (
|
from api.services.workplan_files import (
|
||||||
find_workplan_for_workstream,
|
find_workplan_for_workplan,
|
||||||
patch_task_status,
|
patch_task_status,
|
||||||
patch_workplan_status,
|
patch_workplan_status,
|
||||||
resolve_repo_path,
|
resolve_repo_path,
|
||||||
@@ -33,7 +33,7 @@ from api.services.workplan_files import (
|
|||||||
task_block_linked,
|
task_block_linked,
|
||||||
workplan_status,
|
workplan_status,
|
||||||
)
|
)
|
||||||
from api.workplan_status import normalize_workstream_status
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
|
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification:
|
|||||||
|
|
||||||
|
|
||||||
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
||||||
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
|
result = await session.execute(select(Task.status).where(Task.workplan_id == workstream_id))
|
||||||
statuses = [status_value(row[0]) for row in result.all()]
|
statuses = [status_value(row[0]) for row in result.all()]
|
||||||
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
|
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
|
||||||
|
|
||||||
@@ -98,13 +98,13 @@ async def classify_state_change(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> StateChangeResponse:
|
) -> StateChangeResponse:
|
||||||
if body.target_type == "workstream":
|
if body.target_type == "workstream":
|
||||||
ws = await session.get(Workstream, body.target_id)
|
ws = await session.get(Workplan, body.target_id)
|
||||||
if ws is None:
|
if ws is None:
|
||||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
|
|
||||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
|
repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
|
||||||
repo_path = resolve_repo_path(repo)
|
repo_path = resolve_repo_path(repo)
|
||||||
workplan_ref = find_workplan_for_workstream(repo, ws.id) if repo_path else None
|
workplan_ref = find_workplan_for_workplan(repo, ws.id) if repo_path else None
|
||||||
actual_file_backed = workplan_ref is not None
|
actual_file_backed = workplan_ref is not None
|
||||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||||
file_backed = (
|
file_backed = (
|
||||||
@@ -122,9 +122,9 @@ async def classify_state_change(
|
|||||||
if body.tasks_terminal is not None
|
if body.tasks_terminal is not None
|
||||||
else await _workstream_tasks_terminal(session, ws.id)
|
else await _workstream_tasks_terminal(session, ws.id)
|
||||||
)
|
)
|
||||||
current_status = normalize_workstream_status(ws.status)
|
current_status = normalize_workplan_status(ws.status)
|
||||||
target_status = normalize_workstream_status(body.target_status)
|
target_status = normalize_workplan_status(body.target_status)
|
||||||
classification = classify_workstream_status_change(
|
classification = classify_workplan_status_change(
|
||||||
current_status=current_status,
|
current_status=current_status,
|
||||||
target_status=target_status,
|
target_status=target_status,
|
||||||
file_backed=file_backed,
|
file_backed=file_backed,
|
||||||
@@ -136,7 +136,7 @@ async def classify_state_change(
|
|||||||
conflict = False
|
conflict = False
|
||||||
if body.apply:
|
if body.apply:
|
||||||
expected_status = (
|
expected_status = (
|
||||||
normalize_workstream_status(body.expected_current_status)
|
normalize_workplan_status(body.expected_current_status)
|
||||||
if body.expected_current_status is not None
|
if body.expected_current_status is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -153,7 +153,7 @@ async def classify_state_change(
|
|||||||
)
|
)
|
||||||
conflict = True
|
conflict = True
|
||||||
elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref:
|
elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref:
|
||||||
file_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
file_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||||
if file_status and file_status != current_status:
|
if file_status and file_status != current_status:
|
||||||
classification = _conflict(
|
classification = _conflict(
|
||||||
f"workplan file status {file_status!r} differs from cached DB status {current_status!r}",
|
f"workplan file status {file_status!r} differs from cached DB status {current_status!r}",
|
||||||
@@ -163,7 +163,7 @@ async def classify_state_change(
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
patch_workplan_status(workplan_ref.path, target_status)
|
patch_workplan_status(workplan_ref.path, target_status)
|
||||||
patched_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
patched_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
classification = _conflict(
|
classification = _conflict(
|
||||||
f"workplan file write failed: {exc}",
|
f"workplan file write failed: {exc}",
|
||||||
@@ -178,7 +178,7 @@ async def classify_state_change(
|
|||||||
)
|
)
|
||||||
conflict = True
|
conflict = True
|
||||||
else:
|
else:
|
||||||
transition_workstream_status(ws, target_status)
|
transition_workplan_status(ws, target_status)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
write_result = "applied"
|
write_result = "applied"
|
||||||
|
|
||||||
@@ -221,10 +221,10 @@ async def classify_state_change(
|
|||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
|
repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
|
||||||
repo_path = resolve_repo_path(repo)
|
repo_path = resolve_repo_path(repo)
|
||||||
workplan_ref = find_workplan_for_workstream(repo, ws.id) if ws and repo_path else None
|
workplan_ref = find_workplan_for_workplan(repo, ws.id) if ws and repo_path else None
|
||||||
actual_file_backed = workplan_ref is not None
|
actual_file_backed = workplan_ref is not None
|
||||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||||
file_backed = (
|
file_backed = (
|
||||||
@@ -291,7 +291,7 @@ async def classify_state_change(
|
|||||||
parent_will_activate = should_activate_parent_for_task_start(
|
parent_will_activate = should_activate_parent_for_task_start(
|
||||||
previous_task_status=current_status,
|
previous_task_status=current_status,
|
||||||
new_task_status=target_status,
|
new_task_status=target_status,
|
||||||
parent_workstream_status=ws.status if ws else None,
|
parent_workplan_status=ws.status if ws else None,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
original_text = workplan_ref.path.read_text(encoding="utf-8")
|
original_text = workplan_ref.path.read_text(encoding="utf-8")
|
||||||
@@ -299,7 +299,7 @@ async def classify_state_change(
|
|||||||
patched_status = status_value(task_block_status(workplan_ref.path, task.id))
|
patched_status = status_value(task_block_status(workplan_ref.path, task.id))
|
||||||
if parent_will_activate:
|
if parent_will_activate:
|
||||||
patch_workplan_status(workplan_ref.path, "active")
|
patch_workplan_status(workplan_ref.path, "active")
|
||||||
parent_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
parent_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||||
if parent_status != "active":
|
if parent_status != "active":
|
||||||
if original_text is not None:
|
if original_text is not None:
|
||||||
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy import case, func, select
|
from sqlalchemy import case, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import noload
|
||||||
|
|
||||||
from api.config import settings
|
from api.config import settings
|
||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
@@ -29,11 +30,11 @@ from api.models.managed_repo import ManagedRepo
|
|||||||
from api.models.repo_goal import RepoGoal
|
from api.models.repo_goal import RepoGoal
|
||||||
from api.models.tpsc import TPSCSnapshot
|
from api.models.tpsc import TPSCSnapshot
|
||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||||
from api.schemas.managed_repo import (
|
from api.schemas.managed_repo import (
|
||||||
DispatchTask,
|
DispatchTask,
|
||||||
DispatchWorkstream,
|
DispatchWorkplan,
|
||||||
PendingInterfaceChange,
|
PendingInterfaceChange,
|
||||||
RepoCreate,
|
RepoCreate,
|
||||||
RepoDispatch,
|
RepoDispatch,
|
||||||
@@ -44,6 +45,8 @@ from api.schemas.managed_repo import (
|
|||||||
RepoScopeHealth,
|
RepoScopeHealth,
|
||||||
RepoUpdate,
|
RepoUpdate,
|
||||||
ScopeIssueDetail,
|
ScopeIssueDetail,
|
||||||
|
classification_fields_set,
|
||||||
|
validate_repo_classification_fields,
|
||||||
)
|
)
|
||||||
from hub_core.routers.repos import create_repos_router
|
from hub_core.routers.repos import create_repos_router
|
||||||
|
|
||||||
@@ -76,13 +79,107 @@ def _core_repo_router(**route_flags) -> APIRouter:
|
|||||||
repo_read_schema=RepoRead,
|
repo_read_schema=RepoRead,
|
||||||
repo_path_register_schema=RepoPathRegister,
|
repo_path_register_schema=RepoPathRegister,
|
||||||
list_noload_fields=("goals",),
|
list_noload_fields=("goals",),
|
||||||
create_extension_fields=("topic_id",),
|
create_extension_fields=(
|
||||||
|
"topic_id",
|
||||||
|
"category",
|
||||||
|
"secondary_domains",
|
||||||
|
"capability_tags",
|
||||||
|
"business_stake",
|
||||||
|
"business_mechanics",
|
||||||
|
"classified_at",
|
||||||
|
"classified_by",
|
||||||
|
"standard_version",
|
||||||
|
),
|
||||||
after_register=_publish_repo_registered,
|
after_register=_publish_repo_registered,
|
||||||
**route_flags,
|
**route_flags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
router.include_router(_core_repo_router(include_slug_routes=False))
|
router.include_router(
|
||||||
|
_core_repo_router(include_collection_routes=False, include_slug_routes=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[RepoRead])
|
||||||
|
async def list_repos(
|
||||||
|
response: Response,
|
||||||
|
domain: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
capability_tag: str | None = None,
|
||||||
|
business_stake: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[ManagedRepo]:
|
||||||
|
"""List repos with optional domain and classification filters."""
|
||||||
|
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||||
|
q = (
|
||||||
|
select(ManagedRepo)
|
||||||
|
.options(noload(ManagedRepo.goals))
|
||||||
|
.order_by(ManagedRepo.name)
|
||||||
|
)
|
||||||
|
if domain:
|
||||||
|
domain_result = await session.execute(select(Domain).where(Domain.slug == domain))
|
||||||
|
domain_obj = domain_result.scalar_one_or_none()
|
||||||
|
if domain_obj is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
|
||||||
|
q = q.where(
|
||||||
|
or_(
|
||||||
|
ManagedRepo.domain_id == domain_obj.id,
|
||||||
|
ManagedRepo.secondary_domains.contains([domain]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if category:
|
||||||
|
q = q.where(ManagedRepo.category == category)
|
||||||
|
if capability_tag:
|
||||||
|
q = q.where(ManagedRepo.capability_tags.contains([capability_tag]))
|
||||||
|
if business_stake:
|
||||||
|
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register_repo(
|
||||||
|
body: RepoCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ManagedRepo:
|
||||||
|
domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
|
||||||
|
domain_obj = domain_result.scalar_one_or_none()
|
||||||
|
if domain_obj is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
|
||||||
|
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
|
||||||
|
|
||||||
|
payload = body.model_dump()
|
||||||
|
validate_repo_classification_fields(
|
||||||
|
domain_slug=body.domain_slug,
|
||||||
|
fields=payload,
|
||||||
|
require_complete=classification_fields_set(payload),
|
||||||
|
)
|
||||||
|
repo = ManagedRepo(
|
||||||
|
domain_id=domain_obj.id,
|
||||||
|
slug=body.slug,
|
||||||
|
name=body.name,
|
||||||
|
local_path=body.local_path,
|
||||||
|
host_paths=body.host_paths,
|
||||||
|
remote_url=body.remote_url,
|
||||||
|
git_fingerprint=body.git_fingerprint,
|
||||||
|
description=body.description,
|
||||||
|
topic_id=body.topic_id,
|
||||||
|
category=body.category,
|
||||||
|
secondary_domains=body.secondary_domains,
|
||||||
|
capability_tags=body.capability_tags,
|
||||||
|
business_stake=body.business_stake,
|
||||||
|
business_mechanics=body.business_mechanics,
|
||||||
|
classified_at=body.classified_at,
|
||||||
|
classified_by=body.classified_by,
|
||||||
|
standard_version=body.standard_version,
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
await _publish_repo_registered(repo, body, domain_obj)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.post("/onboard", response_model=RepoOnboardResult)
|
@router.post("/onboard", response_model=RepoOnboardResult)
|
||||||
@@ -428,6 +525,38 @@ async def list_repo_scope_health(
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{slug}", response_model=RepoRead)
|
||||||
|
async def update_repo_with_classification(
|
||||||
|
slug: str,
|
||||||
|
body: RepoUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ManagedRepo:
|
||||||
|
"""Patch repo metadata including classification spine fields."""
|
||||||
|
repo = await _get_repo_by_slug(slug, session)
|
||||||
|
payload = body.model_dump(exclude_unset=True)
|
||||||
|
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
|
||||||
|
domain_obj = domain_result.scalar_one_or_none()
|
||||||
|
domain_slug = domain_obj.slug if domain_obj else ""
|
||||||
|
if classification_fields_set(payload):
|
||||||
|
merged = {
|
||||||
|
"category": payload.get("category", repo.category),
|
||||||
|
"secondary_domains": payload.get("secondary_domains", repo.secondary_domains),
|
||||||
|
"capability_tags": payload.get("capability_tags", repo.capability_tags),
|
||||||
|
"business_stake": payload.get("business_stake", repo.business_stake),
|
||||||
|
"business_mechanics": payload.get("business_mechanics", repo.business_mechanics),
|
||||||
|
}
|
||||||
|
validate_repo_classification_fields(
|
||||||
|
domain_slug=domain_slug,
|
||||||
|
fields=merged,
|
||||||
|
require_complete=True,
|
||||||
|
)
|
||||||
|
for field, value in payload.items():
|
||||||
|
setattr(repo, field, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
router.include_router(
|
router.include_router(
|
||||||
_core_repo_router(
|
_core_repo_router(
|
||||||
include_collection_routes=False,
|
include_collection_routes=False,
|
||||||
@@ -480,19 +609,19 @@ async def get_repo_dispatch(
|
|||||||
|
|
||||||
# Active workstreams
|
# Active workstreams
|
||||||
ws_result = await session.execute(
|
ws_result = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.where(Workstream.repo_id == repo.id, Workstream.status == "active")
|
.where(Workplan.repo_id == repo.id, Workplan.status == "active")
|
||||||
.order_by(Workstream.created_at)
|
.order_by(Workplan.created_at)
|
||||||
)
|
)
|
||||||
workstreams = list(ws_result.scalars().all())
|
workstreams = list(ws_result.scalars().all())
|
||||||
|
|
||||||
dispatch_workstreams: list[DispatchWorkstream] = []
|
dispatch_workstreams: list[DispatchWorkplan] = []
|
||||||
all_interventions: list[DispatchTask] = []
|
all_interventions: list[DispatchTask] = []
|
||||||
|
|
||||||
for ws in workstreams:
|
for ws in workstreams:
|
||||||
task_result = await session.execute(
|
task_result = await session.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "progress"]))
|
.where(Task.workplan_id == ws.id, Task.status.in_(["todo", "progress"]))
|
||||||
.order_by(Task.created_at)
|
.order_by(Task.created_at)
|
||||||
)
|
)
|
||||||
tasks = list(task_result.scalars().all())
|
tasks = list(task_result.scalars().all())
|
||||||
@@ -511,7 +640,7 @@ async def get_repo_dispatch(
|
|||||||
all_interventions.extend(interventions)
|
all_interventions.extend(interventions)
|
||||||
|
|
||||||
dispatch_workstreams.append(
|
dispatch_workstreams.append(
|
||||||
DispatchWorkstream(
|
DispatchWorkplan(
|
||||||
id=ws.id,
|
id=ws.id,
|
||||||
title=ws.title,
|
title=ws.title,
|
||||||
status=ws.status,
|
status=ws.status,
|
||||||
@@ -554,7 +683,7 @@ async def get_repo_dispatch(
|
|||||||
return RepoDispatch(
|
return RepoDispatch(
|
||||||
repo_slug=slug,
|
repo_slug=slug,
|
||||||
active_goal=active_goal,
|
active_goal=active_goal,
|
||||||
active_workstreams=dispatch_workstreams,
|
active_workplans=dispatch_workstreams,
|
||||||
human_interventions=all_interventions,
|
human_interventions=all_interventions,
|
||||||
pending_interface_changes=pending_changes,
|
pending_interface_changes=pending_changes,
|
||||||
scope_needs_review=scope_needs_review,
|
scope_needs_review=scope_needs_review,
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ from api.models.sbom_snapshot import SBOMSnapshot
|
|||||||
from api.models.task import Task, TaskPriority, TaskStatus
|
from api.models.task import Task, TaskPriority, TaskStatus
|
||||||
from api.models.technical_debt import TechnicalDebt
|
from api.models.technical_debt import TechnicalDebt
|
||||||
from api.models.topic import Topic, TopicStatus
|
from api.models.topic import Topic, TopicStatus
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.schemas.decision import DecisionRead
|
from api.schemas.decision import DecisionRead
|
||||||
from api.schemas.domain import DomainSummary
|
from api.schemas.domain import DomainSummary
|
||||||
from api.schemas.progress_event import ProgressEventRead
|
from api.schemas.progress_event import ProgressEventRead
|
||||||
@@ -45,9 +45,9 @@ from api.schemas.workstream_dependency import WorkstreamDepStub
|
|||||||
from api.routers.workstreams import _workplan_index
|
from api.routers.workstreams import _workplan_index
|
||||||
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
||||||
from api.workplan_status import (
|
from api.workplan_status import (
|
||||||
CLOSED_WORKSTREAM_STATUSES,
|
CLOSED_WORKPLAN_STATUSES,
|
||||||
OPEN_WORKSTREAM_STATUSES,
|
OPEN_WORKPLAN_STATUSES,
|
||||||
normalize_workstream_status,
|
normalize_workplan_status,
|
||||||
)
|
)
|
||||||
from task_flow_engine import FlowEngine
|
from task_flow_engine import FlowEngine
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ async def get_summary(
|
|||||||
select(Topic)
|
select(Topic)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Topic.domain),
|
selectinload(Topic.domain),
|
||||||
noload(Topic.workstreams),
|
noload(Topic.workplans),
|
||||||
noload(Topic.decisions),
|
noload(Topic.decisions),
|
||||||
noload(Topic.progress_events),
|
noload(Topic.progress_events),
|
||||||
)
|
)
|
||||||
@@ -96,16 +96,16 @@ async def get_summary(
|
|||||||
if topic_ids:
|
if topic_ids:
|
||||||
topic_ws_rows = await session.execute(
|
topic_ws_rows = await session.execute(
|
||||||
select(
|
select(
|
||||||
Workstream.topic_id,
|
Workplan.topic_id,
|
||||||
Workstream.id,
|
Workplan.id,
|
||||||
Workstream.slug,
|
Workplan.slug,
|
||||||
Workstream.title,
|
Workplan.title,
|
||||||
Workstream.status,
|
Workplan.status,
|
||||||
Workstream.owner,
|
Workplan.owner,
|
||||||
Workstream.due_date,
|
Workplan.due_date,
|
||||||
)
|
)
|
||||||
.where(Workstream.topic_id.in_(topic_ids))
|
.where(Workplan.topic_id.in_(topic_ids))
|
||||||
.order_by(Workstream.created_at)
|
.order_by(Workplan.created_at)
|
||||||
)
|
)
|
||||||
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
|
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
|
||||||
topic_workstreams.setdefault(topic_id, []).append({
|
topic_workstreams.setdefault(topic_id, []).append({
|
||||||
@@ -136,10 +136,10 @@ async def get_summary(
|
|||||||
recent = list(recent_rows.scalars().all())
|
recent = list(recent_rows.scalars().all())
|
||||||
|
|
||||||
open_ws_rows = await session.execute(
|
open_ws_rows = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||||
)
|
)
|
||||||
open_ws = list(open_ws_rows.scalars().all())
|
open_ws = list(open_ws_rows.scalars().all())
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ async def get_summary(
|
|||||||
task_per_ws: dict = {}
|
task_per_ws: dict = {}
|
||||||
task_statuses_per_ws: dict = {}
|
task_statuses_per_ws: dict = {}
|
||||||
for ws_id, tstat, cnt in await session.execute(
|
for ws_id, tstat, cnt in await session.execute(
|
||||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||||
):
|
):
|
||||||
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
||||||
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
|
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
|
||||||
@@ -157,9 +157,9 @@ async def get_summary(
|
|||||||
dep_rows = []
|
dep_rows = []
|
||||||
if open_ws_ids:
|
if open_ws_ids:
|
||||||
dep_result = await session.execute(
|
dep_result = await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dep_rows = list(dep_result.scalars().all())
|
dep_rows = list(dep_result.scalars().all())
|
||||||
@@ -168,16 +168,16 @@ async def get_summary(
|
|||||||
dep_ws_ids = set()
|
dep_ws_ids = set()
|
||||||
dep_task_ids = set()
|
dep_task_ids = set()
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
dep_ws_ids.add(d.from_workstream_id)
|
dep_ws_ids.add(d.from_workplan_id)
|
||||||
if d.to_workstream_id:
|
if d.to_workplan_id:
|
||||||
dep_ws_ids.add(d.to_workstream_id)
|
dep_ws_ids.add(d.to_workplan_id)
|
||||||
if d.to_task_id:
|
if d.to_task_id:
|
||||||
dep_task_ids.add(d.to_task_id)
|
dep_task_ids.add(d.to_task_id)
|
||||||
ws_lookup: dict = {w.id: w for w in open_ws}
|
ws_lookup: dict = {w.id: w for w in open_ws}
|
||||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||||
if extra_ids:
|
if extra_ids:
|
||||||
extra_rows = await session.execute(
|
extra_rows = await session.execute(
|
||||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
|
||||||
)
|
)
|
||||||
for w in extra_rows.scalars():
|
for w in extra_rows.scalars():
|
||||||
ws_lookup[w.id] = w
|
ws_lookup[w.id] = w
|
||||||
@@ -189,7 +189,7 @@ async def get_summary(
|
|||||||
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
||||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
|
||||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||||
dep_id=d.id,
|
dep_id=d.id,
|
||||||
@@ -230,9 +230,9 @@ async def get_summary(
|
|||||||
"workstation": w.status,
|
"workstation": w.status,
|
||||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
|
||||||
for d in dep_rows
|
for d in dep_rows
|
||||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||||
@@ -246,7 +246,7 @@ async def get_summary(
|
|||||||
select(Topic.status, func.count()).group_by(Topic.status)
|
select(Topic.status, func.count()).group_by(Topic.status)
|
||||||
)}
|
)}
|
||||||
ws_counts = {r[0]: r[1] for r in await session.execute(
|
ws_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Workstream.status, func.count()).group_by(Workstream.status)
|
select(Workplan.status, func.count()).group_by(Workplan.status)
|
||||||
)}
|
)}
|
||||||
task_counts = {r[0]: r[1] for r in await session.execute(
|
task_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Task.status, func.count()).group_by(Task.status)
|
select(Task.status, func.count()).group_by(Task.status)
|
||||||
@@ -407,7 +407,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
select(Topic)
|
select(Topic)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Topic.domain),
|
selectinload(Topic.domain),
|
||||||
noload(Topic.workstreams),
|
noload(Topic.workplans),
|
||||||
noload(Topic.decisions),
|
noload(Topic.decisions),
|
||||||
noload(Topic.progress_events),
|
noload(Topic.progress_events),
|
||||||
)
|
)
|
||||||
@@ -418,12 +418,12 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
topic_map = {topic.id: topic for topic in topics}
|
topic_map = {topic.id: topic for topic in topics}
|
||||||
|
|
||||||
workstream_rows = await session.execute(
|
workstream_rows = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.order_by(
|
.order_by(
|
||||||
Workstream.planning_priority.asc().nullslast(),
|
Workplan.planning_priority.asc().nullslast(),
|
||||||
Workstream.planning_order.asc().nullslast(),
|
Workplan.planning_order.asc().nullslast(),
|
||||||
Workstream.updated_at.desc(),
|
Workplan.updated_at.desc(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
workstreams_all = list(workstream_rows.scalars().all())
|
workstreams_all = list(workstream_rows.scalars().all())
|
||||||
@@ -455,7 +455,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
task_statuses_per_ws: dict = {}
|
task_statuses_per_ws: dict = {}
|
||||||
task_totals_by_status: dict[str, int] = {}
|
task_totals_by_status: dict[str, int] = {}
|
||||||
for ws_id, task_status, count in await session.execute(
|
for ws_id, task_status, count in await session.execute(
|
||||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||||
):
|
):
|
||||||
status = status_value(task_status)
|
status = status_value(task_status)
|
||||||
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
|
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
|
||||||
@@ -467,15 +467,15 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
|
|
||||||
open_ws = [
|
open_ws = [
|
||||||
w for w in workstreams_all
|
w for w in workstreams_all
|
||||||
if normalize_workstream_status(w.status) in OPEN_WORKSTREAM_STATUSES
|
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
|
||||||
]
|
]
|
||||||
open_ws_ids = [w.id for w in open_ws]
|
open_ws_ids = [w.id for w in open_ws]
|
||||||
dep_rows = []
|
dep_rows = []
|
||||||
if open_ws_ids:
|
if open_ws_ids:
|
||||||
dep_result = await session.execute(
|
dep_result = await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dep_rows = list(dep_result.scalars().all())
|
dep_rows = list(dep_result.scalars().all())
|
||||||
@@ -490,19 +490,19 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
"workstation": w.status,
|
"workstation": w.status,
|
||||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
|
||||||
for d in dep_rows
|
for d in dep_rows
|
||||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||||
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workstream_status(w.status)
|
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workplan_status(w.status)
|
||||||
|
|
||||||
topic_counts = {r[0]: r[1] for r in await session.execute(
|
topic_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Topic.status, func.count()).group_by(Topic.status)
|
select(Topic.status, func.count()).group_by(Topic.status)
|
||||||
)}
|
)}
|
||||||
ws_counts = {r[0]: r[1] for r in await session.execute(
|
ws_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Workstream.status, func.count()).group_by(Workstream.status)
|
select(Workplan.status, func.count()).group_by(Workplan.status)
|
||||||
)}
|
)}
|
||||||
dec_counts = {r[0]: r[1] for r in await session.execute(
|
dec_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Decision.status, func.count()).group_by(Decision.status)
|
select(Decision.status, func.count()).group_by(Decision.status)
|
||||||
@@ -631,7 +631,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
workplan_rows.append(DashboardWorkplanRow(
|
workplan_rows.append(DashboardWorkplanRow(
|
||||||
id=w.id,
|
id=w.id,
|
||||||
title=w.title,
|
title=w.title,
|
||||||
status=normalize_workstream_status(w.status),
|
status=normalize_workplan_status(w.status),
|
||||||
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
|
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
|
||||||
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
|
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
|
||||||
workplan_filename=workplan.get("filename"),
|
workplan_filename=workplan.get("filename"),
|
||||||
@@ -695,9 +695,9 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
|||||||
# Active workstream counts per domain (join through topics)
|
# Active workstream counts per domain (join through topics)
|
||||||
ws_per_domain = {}
|
ws_per_domain = {}
|
||||||
for domain_id, cnt in await session.execute(
|
for domain_id, cnt in await session.execute(
|
||||||
select(Topic.domain_id, func.count(Workstream.id))
|
select(Topic.domain_id, func.count(Workplan.id))
|
||||||
.join(Workstream, Workstream.topic_id == Topic.id)
|
.join(Workplan, Workplan.topic_id == Topic.id)
|
||||||
.where(Workstream.status.in_(["active", "blocked"]))
|
.where(Workplan.status.in_(["active", "blocked"]))
|
||||||
.group_by(Topic.domain_id)
|
.group_by(Topic.domain_id)
|
||||||
):
|
):
|
||||||
ws_per_domain[domain_id] = cnt
|
ws_per_domain[domain_id] = cnt
|
||||||
@@ -734,10 +734,10 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
Used by workstreams.md and dependencies.md which only need dep edges.
|
Used by workstreams.md and dependencies.md which only need dep edges.
|
||||||
"""
|
"""
|
||||||
open_ws_rows = await session.execute(
|
open_ws_rows = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||||
)
|
)
|
||||||
open_ws = list(open_ws_rows.scalars().all())
|
open_ws = list(open_ws_rows.scalars().all())
|
||||||
|
|
||||||
@@ -745,9 +745,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
dep_rows = []
|
dep_rows = []
|
||||||
if open_ws_ids:
|
if open_ws_ids:
|
||||||
dep_result = await session.execute(
|
dep_result = await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dep_rows = list(dep_result.scalars().all())
|
dep_rows = list(dep_result.scalars().all())
|
||||||
@@ -755,9 +755,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
dep_ws_ids: set = set()
|
dep_ws_ids: set = set()
|
||||||
dep_task_ids: set = set()
|
dep_task_ids: set = set()
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
dep_ws_ids.add(d.from_workstream_id)
|
dep_ws_ids.add(d.from_workplan_id)
|
||||||
if d.to_workstream_id:
|
if d.to_workplan_id:
|
||||||
dep_ws_ids.add(d.to_workstream_id)
|
dep_ws_ids.add(d.to_workplan_id)
|
||||||
if d.to_task_id:
|
if d.to_task_id:
|
||||||
dep_task_ids.add(d.to_task_id)
|
dep_task_ids.add(d.to_task_id)
|
||||||
|
|
||||||
@@ -765,7 +765,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||||
if extra_ids:
|
if extra_ids:
|
||||||
extra_rows = await session.execute(
|
extra_rows = await session.execute(
|
||||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
|
||||||
)
|
)
|
||||||
for w in extra_rows.scalars():
|
for w in extra_rows.scalars():
|
||||||
ws_lookup[w.id] = w
|
ws_lookup[w.id] = w
|
||||||
@@ -777,7 +777,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
|
|
||||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
|
||||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||||
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
|
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
|
||||||
@@ -831,7 +831,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Decision.status == DecisionStatus.resolved)
|
.where(Decision.status == DecisionStatus.resolved)
|
||||||
.where(Decision.decided_at >= cutoff)
|
.where(Decision.decided_at >= cutoff)
|
||||||
.where(Decision.workstream_id.isnot(None))
|
.where(Decision.workplan_id.isnot(None))
|
||||||
.order_by(Decision.decided_at.desc())
|
.order_by(Decision.decided_at.desc())
|
||||||
.limit(20)
|
.limit(20)
|
||||||
)
|
)
|
||||||
@@ -839,7 +839,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
open_tasks_rows = await session.execute(
|
open_tasks_rows = await session.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Task.workstream_id == decision.workstream_id)
|
.where(Task.workplan_id == decision.workplan_id)
|
||||||
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
|
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
|
||||||
)
|
)
|
||||||
open_tasks = list(open_tasks_rows.scalars().all())
|
open_tasks = list(open_tasks_rows.scalars().all())
|
||||||
@@ -848,7 +848,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
||||||
if task.id in seen_task_ids:
|
if task.id in seen_task_ids:
|
||||||
continue
|
continue
|
||||||
ws = await session.get(Workstream, decision.workstream_id, options=[noload("*")])
|
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
|
||||||
domain_slug = await _get_domain_slug_for_workstream(ws, session)
|
domain_slug = await _get_domain_slug_for_workstream(ws, session)
|
||||||
steps.append(NextStep(
|
steps.append(NextStep(
|
||||||
type="resolved_decision",
|
type="resolved_decision",
|
||||||
@@ -868,13 +868,13 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
# ── Signal 2: cleared dependencies ──────────────────────────────────────
|
# ── Signal 2: cleared dependencies ──────────────────────────────────────
|
||||||
all_dep_rows = await session.execute(
|
all_dep_rows = await session.execute(
|
||||||
select(
|
select(
|
||||||
WorkstreamDependency.from_workstream_id,
|
WorkplanDependency.from_workplan_id,
|
||||||
WorkstreamDependency.to_workstream_id,
|
WorkplanDependency.to_workplan_id,
|
||||||
).where(WorkstreamDependency.to_workstream_id.isnot(None))
|
).where(WorkplanDependency.to_workplan_id.isnot(None))
|
||||||
)
|
)
|
||||||
all_deps = all_dep_rows.all()
|
all_deps = all_dep_rows.all()
|
||||||
|
|
||||||
# Group from_workstream_id → set of to_workstream_ids
|
# Group from_workplan_id → set of to_workplan_ids
|
||||||
dep_map: dict = {}
|
dep_map: dict = {}
|
||||||
dep_ws_ids = set()
|
dep_ws_ids = set()
|
||||||
for from_ws_id, to_ws_id in all_deps:
|
for from_ws_id, to_ws_id in all_deps:
|
||||||
@@ -886,12 +886,12 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
if dep_ws_ids:
|
if dep_ws_ids:
|
||||||
ws_rows = await session.execute(
|
ws_rows = await session.execute(
|
||||||
select(
|
select(
|
||||||
Workstream.id,
|
Workplan.id,
|
||||||
Workstream.status,
|
Workplan.status,
|
||||||
Workstream.title,
|
Workplan.title,
|
||||||
Workstream.slug,
|
Workplan.slug,
|
||||||
Workstream.topic_id,
|
Workplan.topic_id,
|
||||||
).where(Workstream.id.in_(dep_ws_ids))
|
).where(Workplan.id.in_(dep_ws_ids))
|
||||||
)
|
)
|
||||||
ws_info = {
|
ws_info = {
|
||||||
ws_id: {
|
ws_id: {
|
||||||
@@ -906,9 +906,9 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
ready_from_ws_ids = [
|
ready_from_ws_ids = [
|
||||||
from_ws_id
|
from_ws_id
|
||||||
for from_ws_id, to_ws_ids in dep_map.items()
|
for from_ws_id, to_ws_ids in dep_map.items()
|
||||||
if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
|
if normalize_workplan_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKPLAN_STATUSES
|
||||||
and all(
|
and all(
|
||||||
normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES
|
normalize_workplan_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKPLAN_STATUSES
|
||||||
for to_id in to_ws_ids
|
for to_id in to_ws_ids
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -918,11 +918,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
todo_rows = await session.execute(
|
todo_rows = await session.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Task.workstream_id.in_(ready_from_ws_ids))
|
.where(Task.workplan_id.in_(ready_from_ws_ids))
|
||||||
.where(Task.status == TaskStatus.todo)
|
.where(Task.status == TaskStatus.todo)
|
||||||
)
|
)
|
||||||
for task in todo_rows.scalars().all():
|
for task in todo_rows.scalars().all():
|
||||||
todo_by_ws.setdefault(task.workstream_id, []).append(task)
|
todo_by_ws.setdefault(task.workplan_id, []).append(task)
|
||||||
|
|
||||||
for from_ws_id in ready_from_ws_ids:
|
for from_ws_id in ready_from_ws_ids:
|
||||||
from_ws = ws_info.get(from_ws_id, {})
|
from_ws = ws_info.get(from_ws_id, {})
|
||||||
@@ -956,7 +956,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
return steps
|
return steps
|
||||||
|
|
||||||
|
|
||||||
async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None:
|
async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None:
|
||||||
"""Get the domain slug for a workstream via its topic."""
|
"""Get the domain slug for a workstream via its topic."""
|
||||||
if ws is None or ws.topic_id is None:
|
if ws is None or ws.topic_id is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from api.database import get_session
|
|||||||
from api.models.progress_event import ProgressEvent
|
from api.models.progress_event import ProgressEvent
|
||||||
from api.models.task import Task, TaskStatus
|
from api.models.task import Task, TaskStatus
|
||||||
from api.models.token_event import TokenEvent
|
from api.models.token_event import TokenEvent
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.task import (
|
from api.schemas.task import (
|
||||||
TaskCountRead,
|
TaskCountRead,
|
||||||
TaskCreate,
|
TaskCreate,
|
||||||
@@ -26,6 +26,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
|
|||||||
|
|
||||||
@router.get("/", response_model=list[TaskRead])
|
@router.get("/", response_model=list[TaskRead])
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
|
workplan_id: uuid.UUID | None = None,
|
||||||
workstream_id: uuid.UUID | None = None,
|
workstream_id: uuid.UUID | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
assignee: str | None = None,
|
assignee: str | None = None,
|
||||||
@@ -37,8 +38,9 @@ async def list_tasks(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[Task]:
|
) -> list[Task]:
|
||||||
q = select(Task)
|
q = select(Task)
|
||||||
if workstream_id:
|
scope_id = workplan_id or workstream_id
|
||||||
q = q.where(Task.workstream_id == workstream_id)
|
if scope_id:
|
||||||
|
q = q.where(Task.workplan_id == scope_id)
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||||
if assignee:
|
if assignee:
|
||||||
@@ -60,18 +62,20 @@ async def list_tasks(
|
|||||||
|
|
||||||
@router.get("/counts", response_model=list[TaskCountRead])
|
@router.get("/counts", response_model=list[TaskCountRead])
|
||||||
async def count_tasks(
|
async def count_tasks(
|
||||||
|
workplan_id: uuid.UUID | None = None,
|
||||||
workstream_id: uuid.UUID | None = None,
|
workstream_id: uuid.UUID | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[TaskCountRead]:
|
) -> list[TaskCountRead]:
|
||||||
q = select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||||
if workstream_id:
|
scope_id = workplan_id or workstream_id
|
||||||
q = q.where(Task.workstream_id == workstream_id)
|
if scope_id:
|
||||||
|
q = q.where(Task.workplan_id == scope_id)
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||||
rows = await session.execute(q)
|
rows = await session.execute(q)
|
||||||
return [
|
return [
|
||||||
TaskCountRead(workstream_id=ws_id, status=task_status, count=count)
|
TaskCountRead(workplan_id=ws_id, status=task_status, count=count)
|
||||||
for ws_id, task_status, count in rows
|
for ws_id, task_status, count in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ async def create_task(
|
|||||||
task = Task(**body.model_dump())
|
task = Task(**body.model_dump())
|
||||||
session.add(task)
|
session.add(task)
|
||||||
if status_value(task.status) == "progress":
|
if status_value(task.status) == "progress":
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
transition_task_status(
|
transition_task_status(
|
||||||
task,
|
task,
|
||||||
task.status,
|
task.status,
|
||||||
@@ -137,7 +141,7 @@ async def bulk_status_sync(
|
|||||||
target_status = status_value(update.status)
|
target_status = status_value(update.status)
|
||||||
if update.blocking_reason is not None:
|
if update.blocking_reason is not None:
|
||||||
task.blocking_reason = update.blocking_reason
|
task.blocking_reason = update.blocking_reason
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
transition_task_status(
|
transition_task_status(
|
||||||
task,
|
task,
|
||||||
update.status,
|
update.status,
|
||||||
@@ -146,7 +150,7 @@ async def bulk_status_sync(
|
|||||||
)
|
)
|
||||||
event = ProgressEvent(
|
event = ProgressEvent(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
workstream_id=task.workstream_id,
|
workplan_id=task.workplan_id,
|
||||||
event_type="task_status_changed",
|
event_type="task_status_changed",
|
||||||
summary=f"Task status -> {target_status}: {task.title}",
|
summary=f"Task status -> {target_status}: {task.title}",
|
||||||
author=author,
|
author=author,
|
||||||
@@ -218,7 +222,7 @@ async def update_task(
|
|||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(task, field, value)
|
setattr(task, field, value)
|
||||||
if new_status is not None:
|
if new_status is not None:
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
transition_task_status(
|
transition_task_status(
|
||||||
task,
|
task,
|
||||||
status_update,
|
status_update,
|
||||||
@@ -247,7 +251,7 @@ async def update_task(
|
|||||||
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
|
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
|
||||||
# Tier 2: prorate workplan total across task count
|
# Tier 2: prorate workplan total across task count
|
||||||
count_result = await session.execute(
|
count_result = await session.execute(
|
||||||
select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id)
|
select(func.count(Task.id)).where(Task.workplan_id == task.workplan_id)
|
||||||
)
|
)
|
||||||
task_count = max(count_result.scalar() or 1, 1)
|
task_count = max(count_result.scalar() or 1, 1)
|
||||||
tin = token_data["workplan_tokens_in"] // task_count
|
tin = token_data["workplan_tokens_in"] // task_count
|
||||||
@@ -273,12 +277,12 @@ async def update_task(
|
|||||||
raw_metadata = {"estimation_method": "fixed_task_done_fallback"}
|
raw_metadata = {"estimation_method": "fixed_task_done_fallback"}
|
||||||
|
|
||||||
# Resolve repo_id via workstream
|
# Resolve repo_id via workstream
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
repo_id = ws.repo_id if ws else None
|
repo_id = ws.repo_id if ws else None
|
||||||
|
|
||||||
event = TokenEvent(
|
event = TokenEvent(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
workstream_id=task.workstream_id,
|
workplan_id=task.workplan_id,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
tokens_in=tin,
|
tokens_in=tin,
|
||||||
tokens_out=tout,
|
tokens_out=tout,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from api.database import get_session
|
|||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.token_event import TokenEvent
|
from api.models.token_event import TokenEvent
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.token_event import (
|
from api.schemas.token_event import (
|
||||||
RepoTokenSummary,
|
RepoTokenSummary,
|
||||||
TokenAggregateRow,
|
TokenAggregateRow,
|
||||||
@@ -102,14 +102,14 @@ def _apply_event_defaults(data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
async def _populate_relationship_defaults(data: dict[str, Any], session: AsyncSession) -> dict[str, Any]:
|
async def _populate_relationship_defaults(data: dict[str, Any], session: AsyncSession) -> dict[str, Any]:
|
||||||
# Auto-populate workstream_id from task if not provided
|
# Auto-populate workstream_id from task if not provided
|
||||||
if data.get("task_id") and not data.get("workstream_id"):
|
if data.get("task_id") and not data.get("workplan_id"):
|
||||||
task = await session.get(Task, data["task_id"])
|
task = await session.get(Task, data["task_id"])
|
||||||
if task:
|
if task:
|
||||||
data["workstream_id"] = task.workstream_id
|
data["workplan_id"] = task.workplan_id
|
||||||
|
|
||||||
# Auto-populate repo_id from workstream if not provided
|
# Auto-populate repo_id from workstream if not provided
|
||||||
if data.get("workstream_id") and not data.get("repo_id"):
|
if data.get("workplan_id") and not data.get("repo_id"):
|
||||||
ws = await session.get(Workstream, data["workstream_id"])
|
ws = await session.get(Workplan, data["workplan_id"])
|
||||||
if ws and ws.repo_id:
|
if ws and ws.repo_id:
|
||||||
data["repo_id"] = ws.repo_id
|
data["repo_id"] = ws.repo_id
|
||||||
return data
|
return data
|
||||||
@@ -169,7 +169,7 @@ def _filter_query(
|
|||||||
if task_id:
|
if task_id:
|
||||||
q = q.where(TokenEvent.task_id == task_id)
|
q = q.where(TokenEvent.task_id == task_id)
|
||||||
if workstream_id:
|
if workstream_id:
|
||||||
q = q.where(TokenEvent.workstream_id == workstream_id)
|
q = q.where(TokenEvent.workplan_id == workstream_id)
|
||||||
if repo_id:
|
if repo_id:
|
||||||
q = q.where(TokenEvent.repo_id == repo_id)
|
q = q.where(TokenEvent.repo_id == repo_id)
|
||||||
if ref_type:
|
if ref_type:
|
||||||
@@ -195,7 +195,7 @@ def _filter_query(
|
|||||||
if unattributed:
|
if unattributed:
|
||||||
q = q.where(
|
q = q.where(
|
||||||
TokenEvent.repo_id.is_(None),
|
TokenEvent.repo_id.is_(None),
|
||||||
TokenEvent.workstream_id.is_(None),
|
TokenEvent.workplan_id.is_(None),
|
||||||
TokenEvent.task_id.is_(None),
|
TokenEvent.task_id.is_(None),
|
||||||
)
|
)
|
||||||
return q
|
return q
|
||||||
@@ -238,7 +238,7 @@ async def get_token_summary(
|
|||||||
uid = uuid.UUID(id)
|
uid = uuid.UUID(id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream")
|
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream")
|
||||||
q = q.where(TokenEvent.workstream_id == uid)
|
q = q.where(TokenEvent.workplan_id == uid)
|
||||||
elif scope == "repo":
|
elif scope == "repo":
|
||||||
try:
|
try:
|
||||||
uid = uuid.UUID(id)
|
uid = uuid.UUID(id)
|
||||||
@@ -297,7 +297,7 @@ async def get_tokens_by_repo(
|
|||||||
Resolution order for each event:
|
Resolution order for each event:
|
||||||
1. token_events.repo_id (direct)
|
1. token_events.repo_id (direct)
|
||||||
2. → workstreams.repo_id (via workstream_id)
|
2. → workstreams.repo_id (via workstream_id)
|
||||||
3. → task.workstream_id → workstreams.repo_id (via task_id)
|
3. → task.workplan_id → workstreams.repo_id (via task_id)
|
||||||
|
|
||||||
Only events that resolve to a repo are included.
|
Only events that resolve to a repo are included.
|
||||||
"""
|
"""
|
||||||
@@ -314,8 +314,8 @@ async def get_tokens_by_repo(
|
|||||||
)
|
)
|
||||||
events = list(events_result.scalars().all())
|
events = list(events_result.scalars().all())
|
||||||
|
|
||||||
ws_result = await session.execute(select(Workstream))
|
ws_result = await session.execute(select(Workplan))
|
||||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
|
||||||
|
|
||||||
task_result = await session.execute(select(Task))
|
task_result = await session.execute(select(Task))
|
||||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
||||||
@@ -326,9 +326,9 @@ async def get_tokens_by_repo(
|
|||||||
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
||||||
if e.repo_id:
|
if e.repo_id:
|
||||||
return e.repo_id
|
return e.repo_id
|
||||||
ws_id = e.workstream_id
|
ws_id = e.workplan_id
|
||||||
if not ws_id and e.task_id and e.task_id in task_map:
|
if not ws_id and e.task_id and e.task_id in task_map:
|
||||||
ws_id = task_map[e.task_id].workstream_id
|
ws_id = task_map[e.task_id].workplan_id
|
||||||
if ws_id and ws_id in ws_map:
|
if ws_id and ws_id in ws_map:
|
||||||
return ws_map[ws_id].repo_id
|
return ws_map[ws_id].repo_id
|
||||||
return None
|
return None
|
||||||
@@ -391,8 +391,8 @@ async def get_token_aggregate(
|
|||||||
)
|
)
|
||||||
events = list(events_result.scalars().all())
|
events = list(events_result.scalars().all())
|
||||||
|
|
||||||
ws_result = await session.execute(select(Workstream))
|
ws_result = await session.execute(select(Workplan))
|
||||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
|
||||||
|
|
||||||
task_result = await session.execute(select(Task))
|
task_result = await session.execute(select(Task))
|
||||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
||||||
@@ -403,9 +403,9 @@ async def get_token_aggregate(
|
|||||||
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
||||||
if e.repo_id:
|
if e.repo_id:
|
||||||
return e.repo_id
|
return e.repo_id
|
||||||
ws_id = e.workstream_id
|
ws_id = e.workplan_id
|
||||||
if not ws_id and e.task_id and e.task_id in task_map:
|
if not ws_id and e.task_id and e.task_id in task_map:
|
||||||
ws_id = task_map[e.task_id].workstream_id
|
ws_id = task_map[e.task_id].workplan_id
|
||||||
if ws_id and ws_id in ws_map:
|
if ws_id and ws_id in ws_map:
|
||||||
return ws_map[ws_id].repo_id
|
return ws_map[ws_id].repo_id
|
||||||
return None
|
return None
|
||||||
@@ -458,7 +458,7 @@ async def get_token_aggregate(
|
|||||||
repo = repo_map.get(rid) if rid else None
|
repo = repo_map.get(rid) if rid else None
|
||||||
add(by_repo, str(rid) if rid else None, repo.slug if repo else None, e)
|
add(by_repo, str(rid) if rid else None, repo.slug if repo else None, e)
|
||||||
|
|
||||||
ws_id = e.workstream_id or (task_map[e.task_id].workstream_id if e.task_id in task_map else None)
|
ws_id = e.workplan_id or (task_map[e.task_id].workplan_id if e.task_id in task_map else None)
|
||||||
ws = ws_map.get(ws_id) if ws_id else None
|
ws = ws_map.get(ws_id) if ws_id else None
|
||||||
add(by_workstream, str(ws_id) if ws_id else None, ws.title if ws else None, e)
|
add(by_workstream, str(ws_id) if ws_id else None, ws.title if ws else None, e)
|
||||||
|
|
||||||
@@ -520,7 +520,7 @@ async def get_token_quality(
|
|||||||
source_counts[(e.measurement_kind, e.source_provider, e.source_id)] += 1
|
source_counts[(e.measurement_kind, e.source_provider, e.source_id)] += 1
|
||||||
if e.source_provider == "task_fallback" or e.note == "heuristic":
|
if e.source_provider == "task_fallback" or e.note == "heuristic":
|
||||||
fallback_count += 1
|
fallback_count += 1
|
||||||
if e.measurement_kind == "measured" and not (e.repo_id or e.workstream_id or e.task_id):
|
if e.measurement_kind == "measured" and not (e.repo_id or e.workplan_id or e.task_id):
|
||||||
unattributed_measured_count += 1
|
unattributed_measured_count += 1
|
||||||
if e.measurement_kind == "measured" and not e.source_id:
|
if e.measurement_kind == "measured" and not e.source_id:
|
||||||
missing_provenance_count += 1
|
missing_provenance_count += 1
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async def list_topics(
|
|||||||
) -> list[Topic]:
|
) -> list[Topic]:
|
||||||
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||||
q = select(Topic).options(
|
q = select(Topic).options(
|
||||||
noload(Topic.workstreams),
|
noload(Topic.workplans),
|
||||||
noload(Topic.decisions),
|
noload(Topic.decisions),
|
||||||
noload(Topic.progress_events),
|
noload(Topic.progress_events),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
|
from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead
|
||||||
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
||||||
|
|
||||||
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
|
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
|
||||||
@@ -17,28 +17,28 @@ workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"])
|
|||||||
|
|
||||||
async def _create_dependency(
|
async def _create_dependency(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
body: WorkstreamDependencyCreate,
|
body: WorkplanDependencyCreate,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> WorkstreamDependency:
|
) -> WorkplanDependency:
|
||||||
if await session.get(Workstream, workstream_id) is None:
|
if await session.get(Workplan, workplan_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="from workplan not found")
|
raise HTTPException(status_code=404, detail="from workplan not found")
|
||||||
|
|
||||||
has_workstream_target = body.to_workstream_id is not None
|
has_workplan_target = body.to_workplan_id is not None
|
||||||
has_task_target = body.to_task_id is not None
|
has_task_target = body.to_task_id is not None
|
||||||
if has_workstream_target == has_task_target:
|
if has_workplan_target == has_task_target:
|
||||||
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
|
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
|
||||||
|
|
||||||
if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None:
|
if body.to_workplan_id and await session.get(Workplan, body.to_workplan_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="target workplan not found")
|
raise HTTPException(status_code=404, detail="target workplan not found")
|
||||||
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
|
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="target task not found")
|
raise HTTPException(status_code=404, detail="target task not found")
|
||||||
if workstream_id == body.to_workstream_id:
|
if workplan_id == body.to_workplan_id:
|
||||||
raise HTTPException(status_code=422, detail="a workplan cannot depend on itself")
|
raise HTTPException(status_code=422, detail="a workplan cannot depend on itself")
|
||||||
|
|
||||||
dep = WorkstreamDependency(
|
dep = WorkplanDependency(
|
||||||
from_workstream_id=workstream_id,
|
from_workplan_id=workplan_id,
|
||||||
to_workstream_id=body.to_workstream_id,
|
to_workplan_id=body.to_workplan_id,
|
||||||
to_task_id=body.to_task_id,
|
to_task_id=body.to_task_id,
|
||||||
relationship_type=body.relationship_type,
|
relationship_type=body.relationship_type,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
@@ -51,15 +51,15 @@ async def _create_dependency(
|
|||||||
|
|
||||||
async def _list_dependencies(
|
async def _list_dependencies(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> list[WorkstreamDependency]:
|
) -> list[WorkplanDependency]:
|
||||||
if await session.get(Workstream, workstream_id) is None:
|
if await session.get(Workplan, workplan_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="workplan not found")
|
raise HTTPException(status_code=404, detail="workplan not found")
|
||||||
rows = await session.execute(
|
rows = await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
(WorkstreamDependency.from_workstream_id == workstream_id)
|
(WorkplanDependency.from_workplan_id == workplan_id)
|
||||||
| (WorkstreamDependency.to_workstream_id == workstream_id)
|
| (WorkplanDependency.to_workplan_id == workplan_id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return list(rows.scalars().all())
|
return list(rows.scalars().all())
|
||||||
@@ -67,14 +67,14 @@ async def _list_dependencies(
|
|||||||
|
|
||||||
async def _delete_dependency(
|
async def _delete_dependency(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
dep_id: uuid.UUID,
|
dep_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
dep = await session.get(WorkstreamDependency, dep_id)
|
dep = await session.get(WorkplanDependency, dep_id)
|
||||||
if dep is None:
|
if dep is None:
|
||||||
raise HTTPException(status_code=404, detail="dependency not found")
|
raise HTTPException(status_code=404, detail="dependency not found")
|
||||||
if dep.from_workstream_id != workstream_id:
|
if dep.from_workplan_id != workplan_id:
|
||||||
raise HTTPException(status_code=403, detail="dependency does not belong to this workplan")
|
raise HTTPException(status_code=403, detail="dependency does not belong to this workplan")
|
||||||
await session.delete(dep)
|
await session.delete(dep)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -82,17 +82,17 @@ async def _delete_dependency(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{workstream_id}/dependencies/",
|
"/{workstream_id}/dependencies/",
|
||||||
response_model=WorkstreamDependencyRead,
|
response_model=WorkplanDependencyRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_dependency(
|
async def create_dependency(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
body: WorkstreamDependencyCreate,
|
body: WorkplanDependencyCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WorkstreamDependency:
|
) -> WorkplanDependency:
|
||||||
"""Record that workstream_id depends on another workstream or a task."""
|
"""Record that workstream_id depends on another workplan or a task."""
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -100,33 +100,33 @@ async def create_dependency(
|
|||||||
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
|
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
|
||||||
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
||||||
)
|
)
|
||||||
return await _create_dependency(workstream_id=workstream_id, body=body, session=session)
|
return await _create_dependency(workplan_id=workstream_id, body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.post(
|
@workplan_router.post(
|
||||||
"/{workplan_id}/dependencies/",
|
"/{workplan_id}/dependencies/",
|
||||||
response_model=WorkstreamDependencyRead,
|
response_model=WorkplanDependencyRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_workplan_dependency(
|
async def create_workplan_dependency(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
body: WorkstreamDependencyCreate,
|
body: WorkplanDependencyCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WorkstreamDependency:
|
) -> WorkplanDependency:
|
||||||
return await _create_dependency(workstream_id=workplan_id, body=body, session=session)
|
return await _create_dependency(workplan_id=workplan_id, body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{workstream_id}/dependencies/",
|
"/{workstream_id}/dependencies/",
|
||||||
response_model=list[WorkstreamDependencyRead],
|
response_model=list[WorkplanDependencyRead],
|
||||||
)
|
)
|
||||||
async def list_dependencies(
|
async def list_dependencies(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[WorkstreamDependency]:
|
) -> list[WorkplanDependency]:
|
||||||
"""Return all dependency edges touching this workstream (both directions)."""
|
"""Return all dependency edges touching this workplan (both directions)."""
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -134,18 +134,18 @@ async def list_dependencies(
|
|||||||
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
|
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
|
||||||
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
||||||
)
|
)
|
||||||
return await _list_dependencies(workstream_id=workstream_id, session=session)
|
return await _list_dependencies(workplan_id=workstream_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.get(
|
@workplan_router.get(
|
||||||
"/{workplan_id}/dependencies/",
|
"/{workplan_id}/dependencies/",
|
||||||
response_model=list[WorkstreamDependencyRead],
|
response_model=list[WorkplanDependencyRead],
|
||||||
)
|
)
|
||||||
async def list_workplan_dependencies(
|
async def list_workplan_dependencies(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[WorkstreamDependency]:
|
) -> list[WorkplanDependency]:
|
||||||
return await _list_dependencies(workstream_id=workplan_id, session=session)
|
return await _list_dependencies(workplan_id=workplan_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -167,7 +167,7 @@ async def delete_dependency(
|
|||||||
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
|
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
|
||||||
replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}",
|
replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}",
|
||||||
)
|
)
|
||||||
await _delete_dependency(workstream_id=workstream_id, dep_id=dep_id, session=session)
|
await _delete_dependency(workplan_id=workstream_id, dep_id=dep_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.delete(
|
@workplan_router.delete(
|
||||||
@@ -179,4 +179,4 @@ async def delete_workplan_dependency(
|
|||||||
dep_id: uuid.UUID,
|
dep_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
await _delete_dependency(workstream_id=workplan_id, dep_id=dep_id, session=session)
|
await _delete_dependency(workplan_id=workplan_id, dep_id=dep_id, session=session)
|
||||||
@@ -15,21 +15,21 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
from api.events import EventEnvelope, publish_event
|
from api.events import EventEnvelope, publish_event
|
||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.workstream import (
|
from api.schemas.workplan import (
|
||||||
WorkstreamCreate,
|
WorkplanCreate,
|
||||||
WorkstreamRead,
|
WorkplanRead,
|
||||||
WorkstreamUpdate,
|
WorkplanUpdate,
|
||||||
)
|
)
|
||||||
from api.services.lifecycle import transition_workstream_status
|
from api.services.lifecycle import transition_workplan_status
|
||||||
from api.services.legacy_meter import (
|
from api.services.legacy_meter import (
|
||||||
LegacyUsageIdentity,
|
LegacyUsageIdentity,
|
||||||
identity_from_request,
|
identity_from_request,
|
||||||
record_legacy_usage,
|
record_legacy_usage,
|
||||||
)
|
)
|
||||||
from api.workplan_status import (
|
from api.workplan_status import (
|
||||||
is_supported_workstream_status,
|
is_supported_workplan_status,
|
||||||
normalize_workstream_status,
|
normalize_workplan_status,
|
||||||
ready_review_status,
|
ready_review_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ async def _meter_legacy_event(
|
|||||||
logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True)
|
logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _list_workstreams(
|
async def _list_workplans(
|
||||||
*,
|
*,
|
||||||
topic_id: uuid.UUID | None,
|
topic_id: uuid.UUID | None,
|
||||||
repo_id: uuid.UUID | None,
|
repo_id: uuid.UUID | None,
|
||||||
@@ -147,27 +147,27 @@ async def _list_workstreams(
|
|||||||
owner: str | None,
|
owner: str | None,
|
||||||
slug: str | None,
|
slug: str | None,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> list[Workstream]:
|
) -> list[Workplan]:
|
||||||
q = select(Workstream)
|
q = select(Workplan)
|
||||||
if topic_id:
|
if topic_id:
|
||||||
q = q.where(Workstream.topic_id == topic_id)
|
q = q.where(Workplan.topic_id == topic_id)
|
||||||
if repo_id:
|
if repo_id:
|
||||||
q = q.where(Workstream.repo_id == repo_id)
|
q = q.where(Workplan.repo_id == repo_id)
|
||||||
if repo_goal_id:
|
if repo_goal_id:
|
||||||
q = q.where(Workstream.repo_goal_id == repo_goal_id)
|
q = q.where(Workplan.repo_goal_id == repo_goal_id)
|
||||||
if status_filter:
|
if status_filter:
|
||||||
normalised_status = normalize_workstream_status(status_filter)
|
normalised_status = normalize_workplan_status(status_filter)
|
||||||
if not is_supported_workstream_status(status_filter):
|
if not is_supported_workplan_status(status_filter):
|
||||||
raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'")
|
raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'")
|
||||||
q = q.where(Workstream.status == normalised_status)
|
q = q.where(Workplan.status == normalised_status)
|
||||||
if owner:
|
if owner:
|
||||||
q = q.where(Workstream.owner == owner)
|
q = q.where(Workplan.owner == owner)
|
||||||
if slug:
|
if slug:
|
||||||
q = q.where(Workstream.slug == slug)
|
q = q.where(Workplan.slug == slug)
|
||||||
q = q.order_by(
|
q = q.order_by(
|
||||||
Workstream.planning_priority.asc().nullslast(),
|
Workplan.planning_priority.asc().nullslast(),
|
||||||
Workstream.planning_order.asc().nullslast(),
|
Workplan.planning_order.asc().nullslast(),
|
||||||
Workstream.updated_at.desc(),
|
Workplan.updated_at.desc(),
|
||||||
)
|
)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
@@ -190,10 +190,10 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
|
|||||||
continue
|
continue
|
||||||
for path in sorted(directory.glob("*.md")):
|
for path in sorted(directory.glob("*.md")):
|
||||||
data = _frontmatter(path)
|
data = _frontmatter(path)
|
||||||
workstream_id = data.get("state_hub_workstream_id")
|
workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id")
|
||||||
if not workstream_id:
|
if not workplan_id:
|
||||||
continue
|
continue
|
||||||
file_status = normalize_workstream_status(data.get("status", ""))
|
file_status = normalize_workplan_status(data.get("status", ""))
|
||||||
review = (
|
review = (
|
||||||
ready_review_status(
|
ready_review_status(
|
||||||
root,
|
root,
|
||||||
@@ -203,7 +203,7 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
|
|||||||
if file_status == "ready"
|
if file_status == "ready"
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
index[str(workstream_id)] = {
|
index[str(workplan_id)] = {
|
||||||
"filename": path.name,
|
"filename": path.name,
|
||||||
"relative_path": str(path.relative_to(root)),
|
"relative_path": str(path.relative_to(root)),
|
||||||
"repo_slug": repo.slug,
|
"repo_slug": repo.slug,
|
||||||
@@ -287,79 +287,79 @@ async def _workplan_index(
|
|||||||
return _INDEX_CACHE
|
return _INDEX_CACHE
|
||||||
|
|
||||||
|
|
||||||
async def _create_workstream(
|
async def _create_workplan(
|
||||||
*,
|
*,
|
||||||
body: WorkstreamCreate,
|
body: WorkplanCreate,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
ws = Workstream(**body.model_dump())
|
wp = Workplan(**body.model_dump())
|
||||||
session.add(ws)
|
session.add(wp)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(ws)
|
await session.refresh(wp)
|
||||||
return ws
|
return wp
|
||||||
|
|
||||||
|
|
||||||
async def _get_workstream(
|
async def _get_workplan(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
ws = await session.get(Workstream, workstream_id)
|
wp = await session.get(Workplan, workplan_id)
|
||||||
if ws is None:
|
if wp is None:
|
||||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
return ws
|
return wp
|
||||||
|
|
||||||
|
|
||||||
async def _update_workstream(
|
async def _update_workplan(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
body: WorkstreamUpdate,
|
body: WorkplanUpdate,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
ws = await session.get(Workstream, workstream_id)
|
wp = await session.get(Workplan, workplan_id)
|
||||||
if ws is None:
|
if wp is None:
|
||||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
update_data = body.model_dump(exclude_unset=True)
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
status_update = update_data.pop("status", None)
|
status_update = update_data.pop("status", None)
|
||||||
prev_status = ws.status
|
prev_status = wp.status
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(ws, field, value)
|
setattr(wp, field, value)
|
||||||
if status_update is not None:
|
if status_update is not None:
|
||||||
transition_workstream_status(ws, status_update)
|
transition_workplan_status(wp, status_update)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(ws)
|
await session.refresh(wp)
|
||||||
|
|
||||||
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
|
if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished":
|
||||||
await _publish_completion_events(ws, session)
|
await _publish_completion_events(wp, session)
|
||||||
|
|
||||||
return ws
|
return wp
|
||||||
|
|
||||||
|
|
||||||
async def _archive_workstream(
|
async def _archive_workplan(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
ws = await session.get(Workstream, workstream_id)
|
wp = await session.get(Workplan, workplan_id)
|
||||||
if ws is None:
|
if wp is None:
|
||||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
transition_workstream_status(ws, "archived")
|
transition_workplan_status(wp, "archived")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(ws)
|
await session.refresh(wp)
|
||||||
return ws
|
return wp
|
||||||
|
|
||||||
|
|
||||||
async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> None:
|
async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> None:
|
||||||
workplan_envelope = EventEnvelope.new(
|
workplan_envelope = EventEnvelope.new(
|
||||||
_COMPLETED_WORKPLAN_EVENT,
|
_COMPLETED_WORKPLAN_EVENT,
|
||||||
attributes={
|
attributes={
|
||||||
"workplan_id": str(ws.id),
|
"workplan_id": str(wp.id),
|
||||||
"legacy_workstream_id": str(ws.id),
|
"legacy_workstream_id": str(wp.id),
|
||||||
"slug": ws.slug,
|
"slug": wp.slug,
|
||||||
"title": ws.title,
|
"title": wp.title,
|
||||||
"topic_id": str(ws.topic_id),
|
"topic_id": str(wp.topic_id) if wp.topic_id else None,
|
||||||
"repo_id": str(ws.repo_id) if ws.repo_id else None,
|
"repo_id": str(wp.repo_id) if wp.repo_id else None,
|
||||||
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
|
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope))
|
asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope))
|
||||||
@@ -372,18 +372,18 @@ async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> N
|
|||||||
legacy_envelope = EventEnvelope.new(
|
legacy_envelope = EventEnvelope.new(
|
||||||
_COMPLETED_WORKSTREAM_EVENT,
|
_COMPLETED_WORKSTREAM_EVENT,
|
||||||
attributes={
|
attributes={
|
||||||
"workstream_id": str(ws.id),
|
"workstream_id": str(wp.id),
|
||||||
"slug": ws.slug,
|
"slug": wp.slug,
|
||||||
"title": ws.title,
|
"title": wp.title,
|
||||||
"topic_id": str(ws.topic_id),
|
"topic_id": str(wp.topic_id) if wp.topic_id else None,
|
||||||
"repo_id": str(ws.repo_id) if ws.repo_id else None,
|
"repo_id": str(wp.repo_id) if wp.repo_id else None,
|
||||||
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
|
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope))
|
asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[WorkstreamRead])
|
@router.get("/", response_model=list[WorkplanRead])
|
||||||
async def list_workstreams(
|
async def list_workstreams(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -394,7 +394,7 @@ async def list_workstreams(
|
|||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[Workstream]:
|
) -> list[Workplan]:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -402,7 +402,7 @@ async def list_workstreams(
|
|||||||
interface_key=_legacy_key("GET", "/workstreams/"),
|
interface_key=_legacy_key("GET", "/workstreams/"),
|
||||||
replacement_ref="/workplans/",
|
replacement_ref="/workplans/",
|
||||||
)
|
)
|
||||||
return await _list_workstreams(
|
return await _list_workplans(
|
||||||
topic_id=topic_id,
|
topic_id=topic_id,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
repo_goal_id=repo_goal_id,
|
repo_goal_id=repo_goal_id,
|
||||||
@@ -413,7 +413,7 @@ async def list_workstreams(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.get("/", response_model=list[WorkstreamRead])
|
@workplan_router.get("/", response_model=list[WorkplanRead])
|
||||||
async def list_workplans(
|
async def list_workplans(
|
||||||
topic_id: uuid.UUID | None = None,
|
topic_id: uuid.UUID | None = None,
|
||||||
repo_id: uuid.UUID | None = None,
|
repo_id: uuid.UUID | None = None,
|
||||||
@@ -422,8 +422,8 @@ async def list_workplans(
|
|||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[Workstream]:
|
) -> list[Workplan]:
|
||||||
return await _list_workstreams(
|
return await _list_workplans(
|
||||||
topic_id=topic_id,
|
topic_id=topic_id,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
repo_goal_id=repo_goal_id,
|
repo_goal_id=repo_goal_id,
|
||||||
@@ -459,13 +459,13 @@ async def workplan_index_preferred(
|
|||||||
return await _workplan_index(refresh=refresh, session=session)
|
return await _workplan_index(refresh=refresh, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_workstream(
|
async def create_workstream(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
body: WorkstreamCreate,
|
body: WorkplanCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -473,24 +473,24 @@ async def create_workstream(
|
|||||||
interface_key=_legacy_key("POST", "/workstreams/"),
|
interface_key=_legacy_key("POST", "/workstreams/"),
|
||||||
replacement_ref="/workplans/",
|
replacement_ref="/workplans/",
|
||||||
)
|
)
|
||||||
return await _create_workstream(body=body, session=session)
|
return await _create_workplan(body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
@workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_workplan(
|
async def create_workplan(
|
||||||
body: WorkstreamCreate,
|
body: WorkplanCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
return await _create_workstream(body=body, session=session)
|
return await _create_workplan(body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{workstream_id}", response_model=WorkstreamRead)
|
@router.get("/{workstream_id}", response_model=WorkplanRead)
|
||||||
async def get_workstream(
|
async def get_workstream(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -498,25 +498,25 @@ async def get_workstream(
|
|||||||
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
|
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
|
||||||
replacement_ref="/workplans/{workplan_id}",
|
replacement_ref="/workplans/{workplan_id}",
|
||||||
)
|
)
|
||||||
return await _get_workstream(workstream_id=workstream_id, session=session)
|
return await _get_workplan(workplan_id=workstream_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.get("/{workplan_id}", response_model=WorkstreamRead)
|
@workplan_router.get("/{workplan_id}", response_model=WorkplanRead)
|
||||||
async def get_workplan(
|
async def get_workplan(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
return await _get_workstream(workstream_id=workplan_id, session=session)
|
return await _get_workplan(workplan_id=workplan_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
|
@router.patch("/{workstream_id}", response_model=WorkplanRead)
|
||||||
async def update_workstream(
|
async def update_workstream(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
body: WorkstreamUpdate,
|
body: WorkplanUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -524,25 +524,25 @@ async def update_workstream(
|
|||||||
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
|
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
|
||||||
replacement_ref="/workplans/{workplan_id}",
|
replacement_ref="/workplans/{workplan_id}",
|
||||||
)
|
)
|
||||||
return await _update_workstream(workstream_id=workstream_id, body=body, session=session)
|
return await _update_workplan(workplan_id=workstream_id, body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.patch("/{workplan_id}", response_model=WorkstreamRead)
|
@workplan_router.patch("/{workplan_id}", response_model=WorkplanRead)
|
||||||
async def update_workplan(
|
async def update_workplan(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
body: WorkstreamUpdate,
|
body: WorkplanUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
return await _update_workstream(workstream_id=workplan_id, body=body, session=session)
|
return await _update_workplan(workplan_id=workplan_id, body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
|
@router.delete("/{workstream_id}", response_model=WorkplanRead)
|
||||||
async def archive_workstream(
|
async def archive_workstream(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -550,12 +550,12 @@ async def archive_workstream(
|
|||||||
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
|
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
|
||||||
replacement_ref="/workplans/{workplan_id}",
|
replacement_ref="/workplans/{workplan_id}",
|
||||||
)
|
)
|
||||||
return await _archive_workstream(workstream_id=workstream_id, session=session)
|
return await _archive_workplan(workplan_id=workstream_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.delete("/{workplan_id}", response_model=WorkstreamRead)
|
@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead)
|
||||||
async def archive_workplan(
|
async def archive_workplan(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
return await _archive_workstream(workstream_id=workplan_id, session=session)
|
return await _archive_workplan(workplan_id=workplan_id, session=session)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
|
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
|
||||||
|
from api.schemas.workplan import WorkplanCreate, WorkplanUpdate, WorkplanRead
|
||||||
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
|
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
|
||||||
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
||||||
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
||||||
@@ -9,6 +10,7 @@ from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
||||||
|
"WorkplanCreate", "WorkplanUpdate", "WorkplanRead",
|
||||||
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
|
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
|
||||||
"TaskCreate", "TaskUpdate", "TaskRead",
|
"TaskCreate", "TaskUpdate", "TaskRead",
|
||||||
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
from hub_core.schemas.capability import (
|
from hub_core.schemas.capability import (
|
||||||
CapabilityRequestDispute,
|
CapabilityRequestDispute,
|
||||||
@@ -23,20 +23,29 @@ class CapabilityRequestCreate(BaseModel):
|
|||||||
priority: str = "medium"
|
priority: str = "medium"
|
||||||
requesting_domain: str # slug, resolved to domain_id in router
|
requesting_domain: str # slug, resolved to domain_id in router
|
||||||
requesting_agent: str
|
requesting_agent: str
|
||||||
requesting_workstream_id: uuid.UUID | None = None
|
requesting_workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("requesting_workplan_id", "requesting_workstream_id"),
|
||||||
|
)
|
||||||
blocking_task_id: uuid.UUID | None = None
|
blocking_task_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRequestAccept(BaseModel):
|
class CapabilityRequestAccept(BaseModel):
|
||||||
fulfilling_agent: str
|
fulfilling_agent: str
|
||||||
fulfilling_workstream_id: uuid.UUID | None = None
|
fulfilling_workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRequestPatch(BaseModel):
|
class CapabilityRequestPatch(BaseModel):
|
||||||
catalog_entry_id: uuid.UUID | None = None
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
blocking_task_id: uuid.UUID | None = None
|
blocking_task_id: uuid.UUID | None = None
|
||||||
fulfilling_workstream_id: uuid.UUID | None = None
|
fulfilling_workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRequestReroute(BaseModel):
|
class CapabilityRequestReroute(BaseModel):
|
||||||
@@ -57,10 +66,10 @@ class CapabilityRequestRead(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
requesting_domain_slug: str
|
requesting_domain_slug: str
|
||||||
requesting_agent: str
|
requesting_agent: str
|
||||||
requesting_workstream_id: uuid.UUID | None = None
|
requesting_workplan_id: uuid.UUID | None = None
|
||||||
fulfilling_domain_slug: str | None = None
|
fulfilling_domain_slug: str | None = None
|
||||||
fulfilling_agent: str | None = None
|
fulfilling_agent: str | None = None
|
||||||
fulfilling_workstream_id: uuid.UUID | None = None
|
fulfilling_workplan_id: uuid.UUID | None = None
|
||||||
blocking_task_id: uuid.UUID | None = None
|
blocking_task_id: uuid.UUID | None = None
|
||||||
catalog_entry_id: uuid.UUID | None = None
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
resolution_note: str | None = None
|
resolution_note: str | None = None
|
||||||
@@ -73,3 +82,13 @@ class CapabilityRequestRead(BaseModel):
|
|||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def requesting_workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.requesting_workplan_id
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def fulfilling_workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.fulfilling_workplan_id
|
||||||
|
|||||||
43
api/schemas/compat.py
Normal file
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
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
from api.models.contribution import ContributionStatus, ContributionType
|
from api.models.contribution import ContributionStatus, ContributionType
|
||||||
|
|
||||||
@@ -14,7 +14,10 @@ class ContributionCreate(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
body_path: str | None = None
|
body_path: str | None = None
|
||||||
related_topic_id: uuid.UUID | None = None
|
related_topic_id: uuid.UUID | None = None
|
||||||
related_workstream_id: uuid.UUID | None = None
|
related_workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("related_workplan_id", "related_workstream_id"),
|
||||||
|
)
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
@@ -36,10 +39,15 @@ class ContributionRead(BaseModel):
|
|||||||
status: ContributionStatus
|
status: ContributionStatus
|
||||||
body_path: str | None = None
|
body_path: str | None = None
|
||||||
related_topic_id: uuid.UUID | None = None
|
related_topic_id: uuid.UUID | None = None
|
||||||
related_workstream_id: uuid.UUID | None = None
|
related_workplan_id: uuid.UUID | None = None
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
submitted_at: datetime | None = None
|
submitted_at: datetime | None = None
|
||||||
resolved_at: datetime | None = None
|
resolved_at: datetime | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def related_workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.related_workplan_id
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ from datetime import datetime
|
|||||||
from pydantic import BaseModel, ConfigDict, model_validator
|
from pydantic import BaseModel, ConfigDict, model_validator
|
||||||
|
|
||||||
from api.models.decision import DecisionStatus, DecisionType
|
from api.models.decision import DecisionStatus, DecisionType
|
||||||
|
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||||
|
from pydantic import AliasChoices, Field
|
||||||
|
|
||||||
|
|
||||||
class DecisionCreate(BaseModel):
|
class DecisionCreate(BaseModel):
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
decision_type: DecisionType = DecisionType.pending
|
decision_type: DecisionType = DecisionType.pending
|
||||||
@@ -20,9 +25,9 @@ class DecisionCreate(BaseModel):
|
|||||||
escalation_note: str | None = None
|
escalation_note: str | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def topic_or_workstream_required(self) -> "DecisionCreate":
|
def topic_or_workplan_required(self) -> "DecisionCreate":
|
||||||
if self.topic_id is None and self.workstream_id is None:
|
if self.topic_id is None and self.workplan_id is None:
|
||||||
raise ValueError("At least one of topic_id or workstream_id must be set")
|
raise ValueError("At least one of topic_id or workplan_id must be set")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -45,11 +50,10 @@ class DecisionUpdate(BaseModel):
|
|||||||
superseded_by: uuid.UUID | None = None
|
superseded_by: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class DecisionRead(BaseModel):
|
class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
decision_type: DecisionType
|
decision_type: DecisionType
|
||||||
@@ -61,4 +65,4 @@ class DecisionRead(BaseModel):
|
|||||||
escalation_note: str | None = None
|
escalation_note: str | None = None
|
||||||
superseded_by: uuid.UUID | None = None
|
superseded_by: uuid.UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
|
|
||||||
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
||||||
@@ -21,7 +21,12 @@ class ExecutionIntentUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ExecutionIntentRead(BaseModel):
|
class ExecutionIntentRead(BaseModel):
|
||||||
workstream_id: uuid.UUID
|
workplan_id: uuid.UUID
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID:
|
||||||
|
return self.workplan_id
|
||||||
execution_state: ExecutionState
|
execution_state: ExecutionState
|
||||||
launch_mode: LaunchMode
|
launch_mode: LaunchMode
|
||||||
concurrency_mode: ConcurrencyMode
|
concurrency_mode: ConcurrencyMode
|
||||||
@@ -31,7 +36,7 @@ class ExecutionIntentRead(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class WorkplanQueueItem(BaseModel):
|
class WorkplanQueueItem(BaseModel):
|
||||||
workstream_id: uuid.UUID
|
workplan_id: uuid.UUID
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
status: str
|
status: str
|
||||||
@@ -45,13 +50,18 @@ class WorkplanQueueItem(BaseModel):
|
|||||||
execution_group: str | None = None
|
execution_group: str | None = None
|
||||||
scheduled_for: datetime | None = None
|
scheduled_for: datetime | None = None
|
||||||
eligible: bool
|
eligible: bool
|
||||||
blocked_by_workstream_ids: list[uuid.UUID] = Field(default_factory=list)
|
blocked_by_workplan_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def blocked_by_workstream_ids(self) -> list[uuid.UUID]:
|
||||||
|
return self.blocked_by_workplan_ids
|
||||||
blocked_by_task_ids: list[uuid.UUID] = Field(default_factory=list)
|
blocked_by_task_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||||
sort_key: list[str | int] = Field(default_factory=list)
|
sort_key: list[str | int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class LaunchRequestCreate(BaseModel):
|
class LaunchRequestCreate(BaseModel):
|
||||||
workstream_id: uuid.UUID
|
workplan_id: uuid.UUID = Field(validation_alias=AliasChoices("workplan_id", "workstream_id"))
|
||||||
requested_by: str = "dashboard"
|
requested_by: str = "dashboard"
|
||||||
requested_actor: str | None = None
|
requested_actor: str | None = None
|
||||||
launch_mode: LaunchMode = "queued"
|
launch_mode: LaunchMode = "queued"
|
||||||
@@ -67,10 +77,15 @@ class LaunchRequestCreate(BaseModel):
|
|||||||
class LaunchRequestRead(BaseModel):
|
class LaunchRequestRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
workstream_id: uuid.UUID
|
workplan_id: uuid.UUID
|
||||||
requested_by: str
|
requested_by: str
|
||||||
requested_actor: str | None = None
|
requested_actor: str | None = None
|
||||||
launch_mode: LaunchMode
|
launch_mode: LaunchMode
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID:
|
||||||
|
return self.workplan_id
|
||||||
concurrency_mode: ConcurrencyMode
|
concurrency_mode: ConcurrencyMode
|
||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from api.models.extension_point import EPStatus
|
from api.models.extension_point import EPStatus
|
||||||
|
|
||||||
@@ -18,7 +18,10 @@ class EPCreate(BaseModel):
|
|||||||
status: EPStatus = EPStatus.open
|
status: EPStatus = EPStatus.open
|
||||||
priority: str = "medium"
|
priority: str = "medium"
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EPUpdate(BaseModel):
|
class EPUpdate(BaseModel):
|
||||||
@@ -29,7 +32,10 @@ class EPUpdate(BaseModel):
|
|||||||
ep_type: str | None = None
|
ep_type: str | None = None
|
||||||
status: EPStatus | None = None
|
status: EPStatus | None = None
|
||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EPRead(BaseModel):
|
class EPRead(BaseModel):
|
||||||
@@ -45,6 +51,10 @@ class EPRead(BaseModel):
|
|||||||
status: EPStatus
|
status: EPStatus
|
||||||
priority: str
|
priority: str
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.workplan_id
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import uuid
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from fastapi import HTTPException
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from api.classification import validate_classification
|
||||||
from hub_core.schemas.managed_repo import (
|
from hub_core.schemas.managed_repo import (
|
||||||
RepoCreate as CoreRepoCreate,
|
RepoCreate as CoreRepoCreate,
|
||||||
RepoPathRegister,
|
RepoPathRegister,
|
||||||
@@ -11,11 +13,73 @@ from hub_core.schemas.managed_repo import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RepoCreate(CoreRepoCreate):
|
class ClassificationFields(BaseModel):
|
||||||
|
category: str | None = None
|
||||||
|
secondary_domains: list[str] | None = None
|
||||||
|
capability_tags: list[str] | None = None
|
||||||
|
business_stake: list[str] | None = None
|
||||||
|
business_mechanics: list[str] | None = None
|
||||||
|
classified_at: date | None = None
|
||||||
|
classified_by: str | None = None
|
||||||
|
standard_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def classification_fields_set(data: dict[str, Any]) -> bool:
|
||||||
|
keys = (
|
||||||
|
"category",
|
||||||
|
"secondary_domains",
|
||||||
|
"capability_tags",
|
||||||
|
"business_stake",
|
||||||
|
"business_mechanics",
|
||||||
|
"classified_at",
|
||||||
|
"classified_by",
|
||||||
|
"standard_version",
|
||||||
|
)
|
||||||
|
return any(data.get(key) is not None for key in keys)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_repo_classification_fields(
|
||||||
|
*,
|
||||||
|
domain_slug: str,
|
||||||
|
fields: dict[str, Any],
|
||||||
|
require_complete: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate classification fields and return normalized values for persistence."""
|
||||||
|
if not classification_fields_set(fields) and not require_complete:
|
||||||
|
return fields
|
||||||
|
|
||||||
|
block = {
|
||||||
|
"category": fields.get("category"),
|
||||||
|
"domain": domain_slug,
|
||||||
|
"secondary_domains": fields.get("secondary_domains") or [],
|
||||||
|
"capability_tags": fields.get("capability_tags") or [],
|
||||||
|
"business_stake": fields.get("business_stake") or [],
|
||||||
|
"business_mechanics": fields.get("business_mechanics") or [],
|
||||||
|
}
|
||||||
|
if require_complete or fields.get("category") is not None:
|
||||||
|
if block["category"] is None:
|
||||||
|
raise HTTPException(status_code=422, detail="`category` is required when classification is provided")
|
||||||
|
if classification_fields_set(fields) and block["category"] is not None:
|
||||||
|
errors, warnings = validate_classification(block)
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(status_code=422, detail={"classification_errors": errors, "warnings": warnings})
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class RepoCreate(CoreRepoCreate, ClassificationFields):
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_classification_on_create(self) -> "RepoCreate":
|
||||||
|
validate_repo_classification_fields(
|
||||||
|
domain_slug=self.domain_slug,
|
||||||
|
fields=self.model_dump(),
|
||||||
|
require_complete=classification_fields_set(self.model_dump()),
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
class RepoUpdate(BaseModel):
|
|
||||||
|
class RepoUpdate(ClassificationFields):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
local_path: str | None = None
|
local_path: str | None = None
|
||||||
remote_url: str | None = None
|
remote_url: str | None = None
|
||||||
@@ -42,7 +106,7 @@ class RepoOnboardResult(BaseModel):
|
|||||||
stderr: str = ""
|
stderr: str = ""
|
||||||
|
|
||||||
|
|
||||||
class RepoRead(CoreRepoRead):
|
class RepoRead(CoreRepoRead, ClassificationFields):
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
sbom_source: str | None = None
|
sbom_source: str | None = None
|
||||||
last_sbom_at: datetime | None = None
|
last_sbom_at: datetime | None = None
|
||||||
@@ -59,13 +123,17 @@ class DispatchTask(BaseModel):
|
|||||||
needs_human: bool
|
needs_human: bool
|
||||||
|
|
||||||
|
|
||||||
class DispatchWorkstream(BaseModel):
|
class DispatchWorkplan(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
title: str
|
title: str
|
||||||
status: str
|
status: str
|
||||||
pending_tasks: list[DispatchTask]
|
pending_tasks: list[DispatchTask]
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy alias
|
||||||
|
DispatchWorkstream = DispatchWorkplan
|
||||||
|
|
||||||
|
|
||||||
class PendingInterfaceChange(BaseModel):
|
class PendingInterfaceChange(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
title: str
|
title: str
|
||||||
@@ -90,13 +158,17 @@ class ScopeIssueDetail(BaseModel):
|
|||||||
class RepoDispatch(BaseModel):
|
class RepoDispatch(BaseModel):
|
||||||
repo_slug: str
|
repo_slug: str
|
||||||
active_goal: dict[str, Any] | None
|
active_goal: dict[str, Any] | None
|
||||||
active_workstreams: list[DispatchWorkstream]
|
active_workplans: list[DispatchWorkplan]
|
||||||
human_interventions: list[DispatchTask]
|
human_interventions: list[DispatchTask]
|
||||||
pending_interface_changes: list[PendingInterfaceChange]
|
pending_interface_changes: list[PendingInterfaceChange]
|
||||||
scope_needs_review: bool
|
scope_needs_review: bool
|
||||||
scope_issue_details: list[ScopeIssueDetail]
|
scope_issue_details: list[ScopeIssueDetail]
|
||||||
last_state_synced_at: datetime | None
|
last_state_synced_at: datetime | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_workstreams(self) -> list[DispatchWorkplan]:
|
||||||
|
return self.active_workplans
|
||||||
|
|
||||||
|
|
||||||
class RepoScopeHealth(BaseModel):
|
class RepoScopeHealth(BaseModel):
|
||||||
repo_slug: str
|
repo_slug: str
|
||||||
@@ -104,4 +176,4 @@ class RepoScopeHealth(BaseModel):
|
|||||||
local_path: str | None = None
|
local_path: str | None = None
|
||||||
path_available: bool
|
path_available: bool
|
||||||
scope_needs_review: bool
|
scope_needs_review: bool
|
||||||
scope_issue_details: list[ScopeIssueDetail]
|
scope_issue_details: list[ScopeIssueDetail]
|
||||||
@@ -2,12 +2,17 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||||
|
|
||||||
|
|
||||||
class ProgressEventCreate(BaseModel):
|
class ProgressEventCreate(BaseModel):
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
decision_id: uuid.UUID | None = None
|
decision_id: uuid.UUID | None = None
|
||||||
event_type: str
|
event_type: str
|
||||||
@@ -17,11 +22,10 @@ class ProgressEventCreate(BaseModel):
|
|||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProgressEventRead(BaseModel):
|
class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
decision_id: uuid.UUID | None = None
|
decision_id: uuid.UUID | None = None
|
||||||
event_type: str
|
event_type: str
|
||||||
@@ -29,4 +33,4 @@ class ProgressEventRead(BaseModel):
|
|||||||
detail: dict[str, Any] | None = None
|
detail: dict[str, Any] | None = None
|
||||||
author: str | None = None
|
author: str | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -5,6 +5,7 @@ from typing import Self
|
|||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
from api.models.task import TaskPriority, TaskStatus
|
from api.models.task import TaskPriority, TaskStatus
|
||||||
|
from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
|
||||||
from api.task_status import normalize_task_status
|
from api.task_status import normalize_task_status
|
||||||
|
|
||||||
|
|
||||||
@@ -17,8 +18,7 @@ class TaskStatusMixin(BaseModel):
|
|||||||
return normalize_task_status(value)
|
return normalize_task_status(value)
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(TaskStatusMixin):
|
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
||||||
workstream_id: uuid.UUID
|
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus = TaskStatus.todo
|
status: TaskStatus = TaskStatus.todo
|
||||||
@@ -96,10 +96,9 @@ class TaskStatusBulkSync(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class TaskRead(TaskStatusMixin):
|
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
workstream_id: uuid.UUID
|
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
@@ -114,8 +113,7 @@ class TaskRead(TaskStatusMixin):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class TaskCountRead(TaskStatusMixin):
|
class TaskCountRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||||
workstream_id: uuid.UUID
|
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from api.models.technical_debt import TDStatus
|
from api.models.technical_debt import TDStatus
|
||||||
|
|
||||||
@@ -35,7 +35,10 @@ class TDCreate(BaseModel):
|
|||||||
severity: str = "medium"
|
severity: str = "medium"
|
||||||
status: TDStatus = TDStatus.open
|
status: TDStatus = TDStatus.open
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TDUpdate(BaseModel):
|
class TDUpdate(BaseModel):
|
||||||
@@ -45,7 +48,10 @@ class TDUpdate(BaseModel):
|
|||||||
debt_type: str | None = None
|
debt_type: str | None = None
|
||||||
severity: str | None = None
|
severity: str | None = None
|
||||||
status: TDStatus | None = None
|
status: TDStatus | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TDRead(BaseModel):
|
class TDRead(BaseModel):
|
||||||
@@ -61,7 +67,11 @@ class TDRead(BaseModel):
|
|||||||
severity: str
|
severity: str
|
||||||
status: TDStatus
|
status: TDStatus
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.workplan_id
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
notes: list[TDNoteRead] = []
|
notes: list[TDNoteRead] = []
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
|
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||||
|
|
||||||
|
|
||||||
class TokenEventCreate(BaseModel):
|
class TokenEventCreate(BaseModel):
|
||||||
tokens_in: int
|
tokens_in: int
|
||||||
tokens_out: int
|
tokens_out: int
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
@@ -32,14 +37,13 @@ class TokenEventCreate(BaseModel):
|
|||||||
raw_metadata: dict[str, Any] | None = None
|
raw_metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenEventRead(BaseModel):
|
class TokenEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
tokens_in: int
|
tokens_in: int
|
||||||
tokens_out: int
|
tokens_out: int
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
@@ -90,7 +94,10 @@ class TokenEventPatch(BaseModel):
|
|||||||
tokens_in: int | None = None
|
tokens_in: int | None = None
|
||||||
tokens_out: int | None = None
|
tokens_out: int | None = None
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|||||||
107
api/schemas/workplan.py
Normal file
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
|
"""Legacy aliases — prefer ``api.schemas.workplan``."""
|
||||||
from datetime import date, datetime
|
from api.schemas.workplan import (
|
||||||
from typing import Literal
|
ConcurrencyMode,
|
||||||
|
ExecutionState,
|
||||||
|
LaunchMode,
|
||||||
|
WorkplanCreate,
|
||||||
|
WorkplanRead,
|
||||||
|
WorkplanStatus,
|
||||||
|
WorkplanStatusMixin,
|
||||||
|
WorkplanUpdate,
|
||||||
|
WorkplanWithDeps,
|
||||||
|
WorkplanWithTaskCounts,
|
||||||
|
)
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
WorkstreamStatus = WorkplanStatus
|
||||||
|
WorkstreamStatusMixin = WorkplanStatusMixin
|
||||||
|
WorkstreamCreate = WorkplanCreate
|
||||||
|
WorkstreamUpdate = WorkplanUpdate
|
||||||
|
WorkstreamRead = WorkplanRead
|
||||||
|
WorkstreamWithTaskCounts = WorkplanWithTaskCounts
|
||||||
|
WorkstreamWithDeps = WorkplanWithDeps
|
||||||
|
|
||||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
__all__ = [
|
||||||
from api.workplan_status import normalize_workstream_status
|
"WorkstreamStatus",
|
||||||
|
"WorkstreamStatusMixin",
|
||||||
WorkstreamStatus = Literal[
|
"WorkstreamCreate",
|
||||||
"proposed",
|
"WorkstreamUpdate",
|
||||||
"ready",
|
"WorkstreamRead",
|
||||||
"active",
|
"WorkstreamWithTaskCounts",
|
||||||
"blocked",
|
"WorkstreamWithDeps",
|
||||||
"backlog",
|
"WorkplanStatus",
|
||||||
"finished",
|
"WorkplanStatusMixin",
|
||||||
"archived",
|
"WorkplanCreate",
|
||||||
]
|
"WorkplanUpdate",
|
||||||
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
"WorkplanRead",
|
||||||
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
|
"WorkplanWithTaskCounts",
|
||||||
ConcurrencyMode = Literal["sequential", "parallel"]
|
"WorkplanWithDeps",
|
||||||
|
"ExecutionState",
|
||||||
|
"LaunchMode",
|
||||||
class WorkstreamStatusMixin(BaseModel):
|
"ConcurrencyMode",
|
||||||
@field_validator("status", mode="before", check_fields=False)
|
]
|
||||||
@classmethod
|
|
||||||
def _normalise_status(cls, value):
|
|
||||||
return normalize_workstream_status(value)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamCreate(WorkstreamStatusMixin):
|
|
||||||
topic_id: uuid.UUID
|
|
||||||
slug: str
|
|
||||||
title: str
|
|
||||||
description: str | None = None
|
|
||||||
status: WorkstreamStatus = "active"
|
|
||||||
owner: str | None = None
|
|
||||||
due_date: date | None = None
|
|
||||||
planning_priority: str | None = None
|
|
||||||
planning_order: int | None = None
|
|
||||||
execution_state: ExecutionState = "manual"
|
|
||||||
launch_mode: LaunchMode = "manual"
|
|
||||||
concurrency_mode: ConcurrencyMode = "sequential"
|
|
||||||
queue_rank: int | None = None
|
|
||||||
execution_group: str | None = None
|
|
||||||
scheduled_for: datetime | None = None
|
|
||||||
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
|
|
||||||
repo_goal_id: uuid.UUID | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamUpdate(WorkstreamStatusMixin):
|
|
||||||
title: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
status: WorkstreamStatus | None = None
|
|
||||||
owner: str | None = None
|
|
||||||
due_date: date | None = None
|
|
||||||
planning_priority: str | None = None
|
|
||||||
planning_order: int | None = None
|
|
||||||
execution_state: ExecutionState | None = None
|
|
||||||
launch_mode: LaunchMode | None = None
|
|
||||||
concurrency_mode: ConcurrencyMode | None = None
|
|
||||||
queue_rank: int | None = None
|
|
||||||
execution_group: str | None = None
|
|
||||||
scheduled_for: datetime | None = None
|
|
||||||
repo_id: uuid.UUID | None = None
|
|
||||||
repo_goal_id: uuid.UUID | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamRead(WorkstreamStatusMixin):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: uuid.UUID
|
|
||||||
topic_id: uuid.UUID
|
|
||||||
repo_id: uuid.UUID | None = None
|
|
||||||
repo_goal_id: uuid.UUID | None = None
|
|
||||||
slug: str
|
|
||||||
title: str
|
|
||||||
description: str | None = None
|
|
||||||
status: WorkstreamStatus
|
|
||||||
owner: str | None = None
|
|
||||||
due_date: date | None = None
|
|
||||||
planning_priority: str | None = None
|
|
||||||
planning_order: int | None = None
|
|
||||||
execution_state: ExecutionState = "manual"
|
|
||||||
launch_mode: LaunchMode = "manual"
|
|
||||||
concurrency_mode: ConcurrencyMode = "sequential"
|
|
||||||
queue_rank: int | None = None
|
|
||||||
execution_group: str | None = None
|
|
||||||
scheduled_for: datetime | None = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamWithTaskCounts(WorkstreamRead):
|
|
||||||
tasks_total: int = 0
|
|
||||||
tasks_wait: int = 0
|
|
||||||
tasks_todo: int = 0
|
|
||||||
tasks_progress: int = 0
|
|
||||||
tasks_done: int = 0
|
|
||||||
tasks_cancel: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamWithDeps(WorkstreamWithTaskCounts):
|
|
||||||
"""WorkstreamWithTaskCounts enriched with dependency graph edges."""
|
|
||||||
depends_on: list[WorkstreamDepStub] = []
|
|
||||||
blocks: list[WorkstreamDepStub] = []
|
|
||||||
blocked_reasons: list[dict] = []
|
|
||||||
@@ -1,36 +1,19 @@
|
|||||||
import uuid
|
"""Legacy aliases — prefer ``api.schemas.workplan_dependency``."""
|
||||||
from datetime import datetime
|
from api.schemas.workplan_dependency import (
|
||||||
|
WorkplanDepStub,
|
||||||
|
WorkplanDependencyCreate,
|
||||||
|
WorkplanDependencyRead,
|
||||||
|
)
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
WorkstreamDependencyCreate = WorkplanDependencyCreate
|
||||||
|
WorkstreamDependencyRead = WorkplanDependencyRead
|
||||||
|
WorkstreamDepStub = WorkplanDepStub
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
class WorkstreamDependencyCreate(BaseModel):
|
"WorkstreamDependencyCreate",
|
||||||
to_workstream_id: uuid.UUID | None = None
|
"WorkstreamDependencyRead",
|
||||||
to_task_id: uuid.UUID | None = None
|
"WorkstreamDepStub",
|
||||||
relationship_type: str = "blocks"
|
"WorkplanDependencyCreate",
|
||||||
description: str | None = None
|
"WorkplanDependencyRead",
|
||||||
|
"WorkplanDepStub",
|
||||||
|
]
|
||||||
class WorkstreamDependencyRead(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: uuid.UUID
|
|
||||||
from_workstream_id: uuid.UUID
|
|
||||||
to_workstream_id: uuid.UUID | None = None
|
|
||||||
to_task_id: uuid.UUID | None = None
|
|
||||||
relationship_type: str
|
|
||||||
description: str | None = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamDepStub(BaseModel):
|
|
||||||
"""Minimal projection of the other end of a dependency edge."""
|
|
||||||
dep_id: uuid.UUID
|
|
||||||
target_type: str = "workstream"
|
|
||||||
relationship_type: str = "blocks"
|
|
||||||
workstream_id: uuid.UUID | None = None
|
|
||||||
workstream_slug: str | None = None
|
|
||||||
workstream_title: str | None = None
|
|
||||||
task_id: uuid.UUID | None = None
|
|
||||||
task_title: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api.workplan_status import normalize_workstream_status
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
|
|
||||||
EXECUTION_STATES = {
|
EXECUTION_STATES = {
|
||||||
@@ -57,7 +57,7 @@ PRIORITY_RANK = {
|
|||||||
"low": 3,
|
"low": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
CLOSED_WORKSTREAM_STATUSES = {"finished", "archived"}
|
CLOSED_WORKPLAN_STATUSES = {"finished", "archived"}
|
||||||
|
|
||||||
|
|
||||||
def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) -> str:
|
def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) -> str:
|
||||||
@@ -71,19 +71,24 @@ def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False)
|
|||||||
return "queued"
|
return "queued"
|
||||||
|
|
||||||
|
|
||||||
def workstream_blockers(
|
def workplan_blockers(
|
||||||
workstream_id: Any,
|
workplan_id: Any,
|
||||||
dependency_targets: dict[Any, list[Any]],
|
dependency_targets: dict[Any, list[Any]],
|
||||||
workstream_status: dict[Any, str],
|
workplan_status: dict[Any, str],
|
||||||
|
workstream_id: Any = None,
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
|
scope_id = workplan_id if workplan_id is not None else workstream_id
|
||||||
blockers = []
|
blockers = []
|
||||||
for target_id in dependency_targets.get(workstream_id, []):
|
for target_id in dependency_targets.get(scope_id, []):
|
||||||
target_status = normalize_workstream_status(workstream_status.get(target_id))
|
target_status = normalize_workplan_status(workplan_status.get(target_id))
|
||||||
if target_status not in CLOSED_WORKSTREAM_STATUSES:
|
if target_status not in CLOSED_WORKPLAN_STATUSES:
|
||||||
blockers.append(target_id)
|
blockers.append(target_id)
|
||||||
return blockers
|
return blockers
|
||||||
|
|
||||||
|
|
||||||
|
workstream_blockers = workplan_blockers
|
||||||
|
|
||||||
|
|
||||||
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
|
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
|
||||||
priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower()
|
priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower()
|
||||||
execution_state = str(getattr(workstream, "execution_state", "") or "manual").strip().lower()
|
execution_state = str(getattr(workstream, "execution_state", "") or "manual").strip().lower()
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ from dataclasses import dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value
|
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value
|
||||||
from api.workplan_status import normalize_workstream_status
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
|
|
||||||
TASK_STARTED_STATUS = "progress"
|
TASK_STARTED_STATUS = "progress"
|
||||||
TASK_NOT_STARTED_STATUS = "todo"
|
TASK_NOT_STARTED_STATUS = "todo"
|
||||||
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
|
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
|
||||||
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
|
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
|
||||||
|
|
||||||
|
# Legacy alias
|
||||||
|
normalize_workstream_status = normalize_workplan_status
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LifecycleTransitionResult:
|
class LifecycleTransitionResult:
|
||||||
@@ -26,13 +28,15 @@ def should_activate_parent_for_task_start(
|
|||||||
*,
|
*,
|
||||||
previous_task_status: Any,
|
previous_task_status: Any,
|
||||||
new_task_status: Any,
|
new_task_status: Any,
|
||||||
parent_workstream_status: Any,
|
parent_workplan_status: Any = None,
|
||||||
|
parent_workstream_status: Any = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return whether a task start should move its parent to active."""
|
"""Return whether a task start should move its parent to active."""
|
||||||
|
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
|
||||||
return (
|
return (
|
||||||
status_value(previous_task_status) == TASK_NOT_STARTED_STATUS
|
status_value(previous_task_status) == TASK_NOT_STARTED_STATUS
|
||||||
and status_value(new_task_status) == TASK_STARTED_STATUS
|
and status_value(new_task_status) == TASK_STARTED_STATUS
|
||||||
and normalize_workstream_status(parent_workstream_status)
|
and normalize_workplan_status(parent_status)
|
||||||
in PARENT_ACTIVATION_STATUSES
|
in PARENT_ACTIVATION_STATUSES
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,12 +48,14 @@ def has_active_task_status(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
|||||||
|
|
||||||
def should_activate_parent_for_active_tasks(
|
def should_activate_parent_for_active_tasks(
|
||||||
*,
|
*,
|
||||||
parent_workstream_status: Any,
|
parent_workplan_status: Any = None,
|
||||||
|
parent_workstream_status: Any = None,
|
||||||
task_statuses: list[Any] | tuple[Any, ...],
|
task_statuses: list[Any] | tuple[Any, ...],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return whether existing task state implies an active parent workstream."""
|
"""Return whether existing task state implies an active parent workplan."""
|
||||||
|
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
|
||||||
return (
|
return (
|
||||||
normalize_workstream_status(parent_workstream_status)
|
normalize_workplan_status(parent_status)
|
||||||
in PARENT_ACTIVATION_STATUSES
|
in PARENT_ACTIVATION_STATUSES
|
||||||
and has_active_task_status(task_statuses)
|
and has_active_task_status(task_statuses)
|
||||||
)
|
)
|
||||||
@@ -59,46 +65,54 @@ def activate_parent_for_task_start(
|
|||||||
*,
|
*,
|
||||||
previous_task_status: Any,
|
previous_task_status: Any,
|
||||||
new_task_status: Any,
|
new_task_status: Any,
|
||||||
parent_workstream: Any,
|
parent_workplan: Any = None,
|
||||||
|
parent_workstream: Any = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Activate a planning-state parent workstream when real task work starts."""
|
"""Activate a planning-state parent workplan when real task work starts."""
|
||||||
if parent_workstream is None:
|
parent = parent_workplan if parent_workplan is not None else parent_workstream
|
||||||
|
if parent is None:
|
||||||
return False
|
return False
|
||||||
if not should_activate_parent_for_task_start(
|
if not should_activate_parent_for_task_start(
|
||||||
previous_task_status=previous_task_status,
|
previous_task_status=previous_task_status,
|
||||||
new_task_status=new_task_status,
|
new_task_status=new_task_status,
|
||||||
parent_workstream_status=getattr(parent_workstream, "status", None),
|
parent_workplan_status=getattr(parent, "status", None),
|
||||||
|
parent_workstream_status=getattr(parent, "status", None),
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
parent_workstream.status = "active"
|
parent.status = "active"
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def transition_workstream_status(
|
def transition_workplan_status(
|
||||||
workstream: Any,
|
workplan: Any,
|
||||||
target_status: Any,
|
target_status: Any,
|
||||||
) -> LifecycleTransitionResult:
|
) -> LifecycleTransitionResult:
|
||||||
"""Apply a canonical workstream status transition."""
|
"""Apply a canonical workplan status transition."""
|
||||||
previous_status = normalize_workstream_status(getattr(workstream, "status", None))
|
previous_status = normalize_workplan_status(getattr(workplan, "status", None))
|
||||||
normalised_target = normalize_workstream_status(target_status)
|
normalised_target = normalize_workplan_status(target_status)
|
||||||
workstream.status = normalised_target
|
workplan.status = normalised_target
|
||||||
return LifecycleTransitionResult(
|
return LifecycleTransitionResult(
|
||||||
entity_type="workstream",
|
entity_type="workplan",
|
||||||
previous_status=previous_status,
|
previous_status=previous_status,
|
||||||
target_status=normalised_target,
|
target_status=normalised_target,
|
||||||
changed=previous_status != normalised_target,
|
changed=previous_status != normalised_target,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
transition_workstream_status = transition_workplan_status
|
||||||
|
|
||||||
|
|
||||||
def transition_task_status(
|
def transition_task_status(
|
||||||
task: Any,
|
task: Any,
|
||||||
target_status: Any,
|
target_status: Any,
|
||||||
*,
|
*,
|
||||||
|
parent_workplan: Any = None,
|
||||||
parent_workstream: Any = None,
|
parent_workstream: Any = None,
|
||||||
previous_task_status: Any = None,
|
previous_task_status: Any = None,
|
||||||
status_coercer: Any = None,
|
status_coercer: Any = None,
|
||||||
) -> LifecycleTransitionResult:
|
) -> LifecycleTransitionResult:
|
||||||
"""Apply a task status transition and activate the parent when work starts."""
|
"""Apply a task status transition and activate the parent when work starts."""
|
||||||
|
parent = parent_workplan if parent_workplan is not None else parent_workstream
|
||||||
previous_status = status_value(
|
previous_status = status_value(
|
||||||
getattr(task, "status", None)
|
getattr(task, "status", None)
|
||||||
if previous_task_status is None
|
if previous_task_status is None
|
||||||
@@ -109,7 +123,8 @@ def transition_task_status(
|
|||||||
parent_activated = activate_parent_for_task_start(
|
parent_activated = activate_parent_for_task_start(
|
||||||
previous_task_status=previous_status,
|
previous_task_status=previous_status,
|
||||||
new_task_status=normalised_target,
|
new_task_status=normalised_target,
|
||||||
parent_workstream=parent_workstream,
|
parent_workplan=parent,
|
||||||
|
parent_workstream=parent,
|
||||||
)
|
)
|
||||||
return LifecycleTransitionResult(
|
return LifecycleTransitionResult(
|
||||||
entity_type="task",
|
entity_type="task",
|
||||||
@@ -117,4 +132,4 @@ def transition_task_status(
|
|||||||
target_status=normalised_target,
|
target_status=normalised_target,
|
||||||
changed=previous_status != normalised_target,
|
changed=previous_status != normalised_target,
|
||||||
parent_activated=parent_activated,
|
parent_activated=parent_activated,
|
||||||
)
|
)
|
||||||
@@ -20,7 +20,7 @@ from api.models.managed_repo import ManagedRepo
|
|||||||
from api.models.progress_event import ProgressEvent
|
from api.models.progress_event import ProgressEvent
|
||||||
from api.models.task import Task, TaskStatus
|
from api.models.task import Task, TaskStatus
|
||||||
from api.models.topic import Topic
|
from api.models.topic import Topic
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.recently_on_scope import (
|
from api.schemas.recently_on_scope import (
|
||||||
RecentlyOnScopeFailedDomain,
|
RecentlyOnScopeFailedDomain,
|
||||||
RecentlyOnScopeHourlyRun,
|
RecentlyOnScopeHourlyRun,
|
||||||
@@ -344,11 +344,11 @@ async def _list_topics(domain_id: uuid.UUID, session: AsyncSession) -> list[Topi
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workstream]:
|
async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workplan]:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.where(_in(Workstream.topic_id, topic_ids))
|
.where(_in(Workplan.topic_id, topic_ids))
|
||||||
.order_by(Workstream.updated_at.desc(), Workstream.created_at.desc())
|
.order_by(Workplan.updated_at.desc(), Workplan.created_at.desc())
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -
|
|||||||
async def _list_tasks(workstream_ids: list[uuid.UUID], session: AsyncSession) -> list[Task]:
|
async def _list_tasks(workstream_ids: list[uuid.UUID], session: AsyncSession) -> list[Task]:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
.where(_in(Task.workstream_id, workstream_ids))
|
.where(_in(Task.workplan_id, workstream_ids))
|
||||||
.order_by(Task.updated_at.desc(), Task.created_at.desc())
|
.order_by(Task.updated_at.desc(), Task.created_at.desc())
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
@@ -370,7 +370,7 @@ async def _list_recent_decisions(
|
|||||||
) -> list[Decision]:
|
) -> list[Decision]:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Decision)
|
select(Decision)
|
||||||
.where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workstream_id, workstream_ids)))
|
.where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workplan_id, workstream_ids)))
|
||||||
.where(
|
.where(
|
||||||
or_(
|
or_(
|
||||||
_between(Decision.created_at, window),
|
_between(Decision.created_at, window),
|
||||||
@@ -397,7 +397,7 @@ async def _list_recent_progress(
|
|||||||
.where(
|
.where(
|
||||||
or_(
|
or_(
|
||||||
_in(ProgressEvent.topic_id, topic_ids),
|
_in(ProgressEvent.topic_id, topic_ids),
|
||||||
_in(ProgressEvent.workstream_id, workstream_ids),
|
_in(ProgressEvent.workplan_id, workstream_ids),
|
||||||
_in(ProgressEvent.task_id, task_ids),
|
_in(ProgressEvent.task_id, task_ids),
|
||||||
_in(ProgressEvent.decision_id, decision_ids),
|
_in(ProgressEvent.decision_id, decision_ids),
|
||||||
)
|
)
|
||||||
@@ -550,7 +550,8 @@ def _progress_data(event: ProgressEvent) -> dict[str, Any]:
|
|||||||
"event_type": event.event_type,
|
"event_type": event.event_type,
|
||||||
"summary": event.summary,
|
"summary": event.summary,
|
||||||
"author": event.author,
|
"author": event.author,
|
||||||
"workstream_id": str(event.workstream_id) if event.workstream_id else None,
|
"workplan_id": str(event.workplan_id) if event.workplan_id else None,
|
||||||
|
"workstream_id": str(event.workplan_id) if event.workplan_id else None,
|
||||||
"task_id": str(event.task_id) if event.task_id else None,
|
"task_id": str(event.task_id) if event.task_id else None,
|
||||||
"decision_id": str(event.decision_id) if event.decision_id else None,
|
"decision_id": str(event.decision_id) if event.decision_id else None,
|
||||||
}
|
}
|
||||||
@@ -569,7 +570,7 @@ def _decision_data(decision: Decision) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _workstream_data(workstream: Workstream) -> dict[str, Any]:
|
def _workstream_data(workstream: Workplan) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": str(workstream.id),
|
"id": str(workstream.id),
|
||||||
"slug": workstream.slug,
|
"slug": workstream.slug,
|
||||||
@@ -584,7 +585,7 @@ def _workstream_data(workstream: Workstream) -> dict[str, Any]:
|
|||||||
def _task_data(task: Task) -> dict[str, Any]:
|
def _task_data(task: Task) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": str(task.id),
|
"id": str(task.id),
|
||||||
"workstream_id": str(task.workstream_id),
|
"workstream_id": str(task.workplan_id),
|
||||||
"title": task.title,
|
"title": task.title,
|
||||||
"status": _enum_value(task.status),
|
"status": _enum_value(task.status),
|
||||||
"priority": _enum_value(task.priority),
|
"priority": _enum_value(task.priority),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from api.services.lifecycle import status_value
|
from api.services.lifecycle import status_value
|
||||||
from api.task_status import CANONICAL_TASK_STATUSES
|
from api.task_status import CANONICAL_TASK_STATUSES
|
||||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
|
||||||
|
|
||||||
|
|
||||||
class ReconciliationClass(str, Enum):
|
class ReconciliationClass(str, Enum):
|
||||||
@@ -22,11 +22,11 @@ class StateChangeClassification:
|
|||||||
follow_up: str
|
follow_up: str
|
||||||
|
|
||||||
|
|
||||||
WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"}
|
WRITE_THROUGH_WORKPLAN_STATUSES = {"proposed", "ready", "active", "backlog"}
|
||||||
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
|
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
def classify_workstream_status_change(
|
def classify_workplan_status_change(
|
||||||
*,
|
*,
|
||||||
current_status: Any,
|
current_status: Any,
|
||||||
target_status: Any,
|
target_status: Any,
|
||||||
@@ -35,8 +35,8 @@ def classify_workstream_status_change(
|
|||||||
tasks_terminal: bool | None = None,
|
tasks_terminal: bool | None = None,
|
||||||
) -> StateChangeClassification:
|
) -> StateChangeClassification:
|
||||||
"""Classify a UI-originated workstream status transition."""
|
"""Classify a UI-originated workstream status transition."""
|
||||||
current = normalize_workstream_status(current_status)
|
current = normalize_workplan_status(current_status)
|
||||||
target = normalize_workstream_status(target_status)
|
target = normalize_workplan_status(target_status)
|
||||||
|
|
||||||
if not file_backed:
|
if not file_backed:
|
||||||
return StateChangeClassification(
|
return StateChangeClassification(
|
||||||
@@ -56,7 +56,7 @@ def classify_workstream_status_change(
|
|||||||
"status is unchanged",
|
"status is unchanged",
|
||||||
"no file update required",
|
"no file update required",
|
||||||
)
|
)
|
||||||
if target in WRITE_THROUGH_WORKSTREAM_STATUSES and current not in CLOSED_WORKSTREAM_STATUSES:
|
if target in WRITE_THROUGH_WORKPLAN_STATUSES and current not in CLOSED_WORKPLAN_STATUSES:
|
||||||
return StateChangeClassification(
|
return StateChangeClassification(
|
||||||
ReconciliationClass.WRITE_THROUGH,
|
ReconciliationClass.WRITE_THROUGH,
|
||||||
"open lifecycle transition can be represented in workplan frontmatter",
|
"open lifecycle transition can be represented in workplan frontmatter",
|
||||||
@@ -93,6 +93,9 @@ def classify_workstream_status_change(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
classify_workstream_status_change = classify_workplan_status_change
|
||||||
|
|
||||||
|
|
||||||
def classify_task_status_change(
|
def classify_task_status_change(
|
||||||
*,
|
*,
|
||||||
current_status: Any,
|
current_status: Any,
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ def resolve_repo_path(repo: ManagedRepo | None) -> Path | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_workplan_for_workstream(
|
def find_workplan_for_workplan(
|
||||||
repo: ManagedRepo | None,
|
repo: ManagedRepo | None,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
) -> WorkplanFileRef | None:
|
) -> WorkplanFileRef | None:
|
||||||
repo_path = resolve_repo_path(repo)
|
repo_path = resolve_repo_path(repo)
|
||||||
if repo_path is None:
|
if repo_path is None:
|
||||||
@@ -57,11 +57,15 @@ def find_workplan_for_workstream(
|
|||||||
continue
|
continue
|
||||||
for path in sorted(directory.glob("*.md")):
|
for path in sorted(directory.glob("*.md")):
|
||||||
meta = _frontmatter(path)
|
meta = _frontmatter(path)
|
||||||
if str(meta.get("state_hub_workstream_id", "")).strip().strip('"') == str(workstream_id):
|
file_id = meta.get("state_hub_workplan_id") or meta.get("state_hub_workstream_id")
|
||||||
|
if str(file_id or "").strip().strip('"') == str(workplan_id):
|
||||||
return WorkplanFileRef(repo_path=repo_path, path=path, archived=archived)
|
return WorkplanFileRef(repo_path=repo_path, path=path, archived=archived)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
find_workplan_for_workstream = find_workplan_for_workplan
|
||||||
|
|
||||||
|
|
||||||
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
|
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
|
||||||
return _task_block_for_task(path, task_id) is not None
|
return _task_block_for_task(path, task_id) is not None
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
CANONICAL_WORKPLAN_STATUSES: tuple[str, ...] = (
|
||||||
"proposed",
|
"proposed",
|
||||||
"ready",
|
"ready",
|
||||||
"active",
|
"active",
|
||||||
@@ -17,22 +17,31 @@ CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
|||||||
"archived",
|
"archived",
|
||||||
)
|
)
|
||||||
|
|
||||||
LEGACY_WORKSTREAM_STATUS_ALIASES: dict[str, str] = {
|
LEGACY_WORKPLAN_STATUS_ALIASES: dict[str, str] = {
|
||||||
"todo": "ready",
|
"todo": "ready",
|
||||||
"done": "finished",
|
"done": "finished",
|
||||||
"completed": "finished",
|
"completed": "finished",
|
||||||
"accepted": "finished",
|
"accepted": "finished",
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPPORTED_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
SUPPORTED_WORKPLAN_STATUSES: tuple[str, ...] = (
|
||||||
*CANONICAL_WORKSTREAM_STATUSES,
|
*CANONICAL_WORKPLAN_STATUSES,
|
||||||
*LEGACY_WORKSTREAM_STATUS_ALIASES.keys(),
|
*LEGACY_WORKPLAN_STATUS_ALIASES.keys(),
|
||||||
)
|
)
|
||||||
|
|
||||||
OPEN_WORKSTREAM_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
OPEN_WORKPLAN_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
||||||
CURRENT_WORKSTREAM_STATUSES: tuple[str, ...] = ("active", "blocked")
|
CURRENT_WORKPLAN_STATUSES: tuple[str, ...] = ("active", "blocked")
|
||||||
CLOSED_WORKSTREAM_STATUSES: tuple[str, ...] = ("finished", "archived")
|
CLOSED_WORKPLAN_STATUSES: tuple[str, ...] = ("finished", "archived")
|
||||||
PLANNING_WORKSTREAM_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
|
PLANNING_WORKPLAN_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
|
||||||
|
|
||||||
|
# Legacy aliases (workstream terminology)
|
||||||
|
CANONICAL_WORKSTREAM_STATUSES = CANONICAL_WORKPLAN_STATUSES
|
||||||
|
LEGACY_WORKSTREAM_STATUS_ALIASES = LEGACY_WORKPLAN_STATUS_ALIASES
|
||||||
|
SUPPORTED_WORKSTREAM_STATUSES = SUPPORTED_WORKPLAN_STATUSES
|
||||||
|
OPEN_WORKSTREAM_STATUSES = OPEN_WORKPLAN_STATUSES
|
||||||
|
CURRENT_WORKSTREAM_STATUSES = CURRENT_WORKPLAN_STATUSES
|
||||||
|
CLOSED_WORKSTREAM_STATUSES = CLOSED_WORKPLAN_STATUSES
|
||||||
|
PLANNING_WORKSTREAM_STATUSES = PLANNING_WORKPLAN_STATUSES
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -42,26 +51,38 @@ class ReadyReviewStatus:
|
|||||||
changed_paths: tuple[str, ...] = ()
|
changed_paths: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def normalize_workstream_status(status: Any, *, has_started: bool | None = None) -> str:
|
def normalize_workplan_status(status: Any, *, has_started: bool | None = None) -> str:
|
||||||
"""Return the canonical lifecycle status for a stored or legacy value."""
|
"""Return the canonical lifecycle status for a stored or legacy value."""
|
||||||
value = _status_value(status)
|
value = _status_value(status)
|
||||||
if value == "todo" and has_started:
|
if value == "todo" and has_started:
|
||||||
return "active"
|
return "active"
|
||||||
return LEGACY_WORKSTREAM_STATUS_ALIASES.get(value, value)
|
return LEGACY_WORKPLAN_STATUS_ALIASES.get(value, value)
|
||||||
|
|
||||||
|
|
||||||
def is_canonical_workstream_status(status: Any) -> bool:
|
normalize_workstream_status = normalize_workplan_status
|
||||||
return _status_value(status) in CANONICAL_WORKSTREAM_STATUSES
|
|
||||||
|
|
||||||
|
|
||||||
def is_supported_workstream_status(status: Any) -> bool:
|
def is_canonical_workplan_status(status: Any) -> bool:
|
||||||
return _status_value(status) in SUPPORTED_WORKSTREAM_STATUSES
|
return _status_value(status) in CANONICAL_WORKPLAN_STATUSES
|
||||||
|
|
||||||
|
|
||||||
def workstream_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
is_canonical_workstream_status = is_canonical_workplan_status
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_workplan_status(status: Any) -> bool:
|
||||||
|
return _status_value(status) in SUPPORTED_WORKPLAN_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
is_supported_workstream_status = is_supported_workplan_status
|
||||||
|
|
||||||
|
|
||||||
|
def workplan_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
||||||
return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
|
return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
|
||||||
|
|
||||||
|
|
||||||
|
workstream_has_started = workplan_has_started
|
||||||
|
|
||||||
|
|
||||||
def ready_review_status(
|
def ready_review_status(
|
||||||
repo_dir: str | Path,
|
repo_dir: str | Path,
|
||||||
reviewed_against_commit: Any,
|
reviewed_against_commit: Any,
|
||||||
|
|||||||
@@ -4,27 +4,36 @@ title: Domains — Reference
|
|||||||
|
|
||||||
# Domains — Reference
|
# Domains — Reference
|
||||||
|
|
||||||
The Domains page shows all registered project domains and the repositories
|
The Domains page shows the **14 fixed market domains** from the Repo
|
||||||
associated with each one. Domains are the top-level organisational unit of the
|
Classification Standard. These replaced the old ad-hoc coordination domains
|
||||||
Custodian ecosystem.
|
(custodian, railiance, markitect, …) in STATE-WP-0065.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What is a domain?
|
## What is a domain?
|
||||||
|
|
||||||
A domain corresponds to one of the six tracked project areas:
|
A domain is an intended **market / user segment** — not a project org unit.
|
||||||
|
Each registered repo has exactly one primary domain (from its
|
||||||
|
`.repo-classification.yaml`), stored on `managed_repos.domain_id`.
|
||||||
|
|
||||||
| Slug | Project |
|
| Slug | Segment |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `custodian` | The Custodian agent system itself |
|
| `infotech` | Developers, platforms, internal tooling users |
|
||||||
| `railiance` | DevOps & infrastructure reliability |
|
| `financials` | Finance, trading, payments |
|
||||||
| `markitect` | Knowledge artifact management |
|
| `communication` | Messaging, social, collaboration |
|
||||||
| `coulomb_social` | Co-creation marketplace |
|
| `consumer` | General consumers |
|
||||||
| `personhood` | Rights & obligations framework |
|
| `health` | Healthcare, wellness |
|
||||||
| `foerster_capabilities` | Agency capability taxonomy |
|
| `industrials` | Manufacturing, logistics |
|
||||||
|
| `energy` | Energy sector |
|
||||||
|
| `utilities` | Utilities infrastructure |
|
||||||
|
| `materials` | Materials / commodities |
|
||||||
|
| `realestate` | Property, housing |
|
||||||
|
| `crypto` | Crypto / web3 |
|
||||||
|
| `agents` | AI-native agent users |
|
||||||
|
| `space` | Space industry |
|
||||||
|
| `government` | Civic, public sector |
|
||||||
|
|
||||||
Each domain has a slug (URL-friendly identifier), a human-readable name, an
|
Canon: `the-custodian/canon/standards/repo-classification-standard_v1.0.md`
|
||||||
optional description, and a status.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,63 +41,21 @@ optional description, and a status.
|
|||||||
|
|
||||||
| Status | Meaning |
|
| Status | Meaning |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| **active** | Live domain — topics, workstreams, and tasks are being tracked |
|
| **active** | Live domain — repos and workplans may reference it |
|
||||||
| **archived** | Soft-deleted; no active work. Fails to archive if active topics exist |
|
| **archived** | Retired; no new registrations |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## KPI row
|
## Relationship to repos and workplans
|
||||||
|
|
||||||
Four counters at the top of the page:
|
- **Repos** are the primary anchor — classification file is source of truth.
|
||||||
|
- **Workplans** require `repo_id`; market domain is derived from the repo.
|
||||||
| Counter | Meaning |
|
- **Topics** are optional legacy tags; workplan frontmatter `domain:` may still
|
||||||
|---------|---------|
|
use old coordination slugs — the consistency checker maps these to market domains.
|
||||||
| Total domains | All registered domains regardless of status |
|
|
||||||
| Active | Domains with status `active` |
|
|
||||||
| Total repos | Sum of all registered repositories across all domains |
|
|
||||||
| Newest domain | Name of the most recently created domain |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Domain cards
|
## Related
|
||||||
|
|
||||||
One card per domain showing:
|
- **[Repos](/docs/repos)** — portfolio view with category / capability filters
|
||||||
|
- **[Repo Integration](/docs/repo-integration)** — onboarding with classification file
|
||||||
- **Slug** — monospace identifier
|
|
||||||
- **Status badge** — green `active` or grey `archived`
|
|
||||||
- **Name** — display name
|
|
||||||
- **Description** — first 160 characters
|
|
||||||
- **Repos** — list of registered repositories for this domain, each showing name, local path, and remote URL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RecentlyOnScope
|
|
||||||
|
|
||||||
The `Domains / RecentlyOnScope` page generates deterministic Markdown digests
|
|
||||||
for a selected domain. The range parameter defaults to `1h` and accepts compact
|
|
||||||
durations such as `15m`, `6h`, or `1d`.
|
|
||||||
|
|
||||||
Generated reports are written under the configured State Hub report directory,
|
|
||||||
defaulting to `reports/recently-on-scope/<domain_slug>/`. The dashboard lists
|
|
||||||
those Markdown files and previews the raw report content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Managing domains
|
|
||||||
|
|
||||||
Via MCP:
|
|
||||||
|
|
||||||
```
|
|
||||||
create_domain(slug="my_project", name="My Project", description="…")
|
|
||||||
rename_domain(slug="old_slug", new_slug="new_slug", new_name="New Name")
|
|
||||||
archive_domain(slug="my_project") # fails if active topics exist
|
|
||||||
```
|
|
||||||
|
|
||||||
Via Makefile:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make add-domain SLUG=my_project NAME="My Project"
|
|
||||||
make rename-domain OLD_SLUG=my_project NEW_SLUG=myproject NEW_NAME="My Project"
|
|
||||||
```
|
|
||||||
|
|
||||||
*Domains are never hard-deleted — only archived.*
|
|
||||||
@@ -5,18 +5,25 @@ title: Repos — Reference
|
|||||||
# Repos — Reference
|
# Repos — Reference
|
||||||
|
|
||||||
The Repos page shows every repository registered in the Custodian ecosystem,
|
The Repos page shows every repository registered in the Custodian ecosystem,
|
||||||
their SBOM ingestion status, and a domain-grouped coverage map.
|
their **classification** (category, market domain, capabilities, business stake),
|
||||||
|
SBOM ingestion status, and a domain-grouped coverage map.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What is a managed repo?
|
## What is a managed repo?
|
||||||
|
|
||||||
A managed repo is a git repository that has been registered with the state hub
|
A managed repo is a git repository registered with State Hub. Registration is
|
||||||
via `custodian register-project` or `register_repo()`. Registration records the
|
**classification-driven**:
|
||||||
repo's slug, domain, local path, and optional remote URL. Once registered, the
|
|
||||||
repo receives a `CLAUDE.custodian.md` integration suggestion, an onboarding
|
1. Commit `.repo-classification.yaml` per the Repo Classification Standard.
|
||||||
workstream with 4 tasks for the repo agent, and is eligible for SBOM ingestion
|
2. Run `make register-from-classification REPO=<slug>` (or use the MCP tool
|
||||||
and the ADR-001 workplan validator.
|
`register_repo_from_classification`).
|
||||||
|
|
||||||
|
The file is the source of truth; the hub stores a validated copy on
|
||||||
|
`managed_repos` (category, domain, capability_tags, business_stake, provenance).
|
||||||
|
|
||||||
|
Legacy `custodian register-project` still works for agent onboarding but should
|
||||||
|
be followed by classification registration.
|
||||||
|
|
||||||
For the full onboarding journey see **[Repo Integration](/docs/repo-integration)**.
|
For the full onboarding journey see **[Repo Integration](/docs/repo-integration)**.
|
||||||
|
|
||||||
@@ -27,69 +34,56 @@ For the full onboarding journey see **[Repo Integration](/docs/repo-integration)
|
|||||||
| Card | Meaning |
|
| Card | Meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| **Registered Repos** | Active repos only (status = active) |
|
| **Registered Repos** | Active repos only (status = active) |
|
||||||
| **Domains** | Count of distinct domain slugs across registered repos |
|
| **Market Domains** | Distinct primary domains across registered repos |
|
||||||
|
| **Categories** | Distinct work categories (experimental, tooling, product, …) |
|
||||||
| **SBOM Ingested** | Repos with at least one SBOM snapshot |
|
| **SBOM Ingested** | Repos with at least one SBOM snapshot |
|
||||||
| **SBOM Gaps** | Repos with no ingested SBOM — red border when > 0 |
|
| **SBOM Gaps** | Repos with no ingested SBOM — red border when > 0 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Coverage Map
|
## Portfolio by Category
|
||||||
|
|
||||||
Groups repos by domain. Each domain block shows:
|
Groups repos by `category` (experimental, research, project, tooling, product,
|
||||||
|
business). Each block shows domain, capabilities, business stake, and who
|
||||||
- **Domain name** with SBOM, EP, and TD chip indicators
|
classified the repo (`human` vs `migration`).
|
||||||
- **SBOM chip** — green ✓ if all repos in the domain are ingested, amber ⚠ if any gap exists
|
|
||||||
- **EPs chip** — count of open/in-progress extension points for this domain
|
|
||||||
- **TDs chip** — count of open/in-progress technical debt items for this domain
|
|
||||||
- **Repo table** — one row per repo with SBOM status, package count, and local path
|
|
||||||
|
|
||||||
Rows with no SBOM are highlighted in amber.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Filters
|
## Coverage Map
|
||||||
|
|
||||||
|
Groups repos by **market domain**. Each domain block shows SBOM, EP, and TD
|
||||||
|
chips plus per-repo classification columns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filters (All Repos Table)
|
||||||
|
|
||||||
| Filter | Effect |
|
| Filter | Effect |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| **Domain** | Show repos for a single domain only |
|
| **Market domain** | Primary domain slug |
|
||||||
| **Gaps only** | Toggle to show only repos without an ingested SBOM |
|
| **Category** | Repo work category |
|
||||||
|
| **Capability** | Repos tagged with a capability |
|
||||||
|
| **Business stake** | Repos affecting a business responsibility area |
|
||||||
|
| **DoI tier** | Definition of Integrated tier |
|
||||||
|
| **Gaps only** | Repos without ingested SBOM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consistency (C-24)
|
||||||
|
|
||||||
|
The ADR-001 consistency checker warns when a registered repo lacks a valid
|
||||||
|
`.repo-classification.yaml` on disk. Migration-derived rows (`classified_by:
|
||||||
|
migration`) get an explanatory note until a human-reviewed file is committed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Onboarding a new repo
|
## Onboarding a new repo
|
||||||
|
|
||||||
See **[Repo Integration](/docs/repo-integration)** for the full journey.
|
Use the **Add Repo** form or:
|
||||||
|
|
||||||
Quick reference:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the repo root — registers, writes CLAUDE.custodian.md, creates onboarding tasks
|
# 1. Author classification file in the repo
|
||||||
custodian register-project --domain <slug>
|
# 2. Register / reclassify
|
||||||
```
|
make register-from-classification PATH=/path/to/repo
|
||||||
|
make fix-consistency REPO=<slug>
|
||||||
## Ingesting a repo's SBOM
|
```
|
||||||
|
|
||||||
```bash
|
|
||||||
# Auto-detects lockfile at repo root
|
|
||||||
cd ~/state-hub
|
|
||||||
make ingest-sbom REPO=<slug> REPO_PATH=/absolute/path
|
|
||||||
|
|
||||||
# Multi-ecosystem repo — scan all lockfiles recursively
|
|
||||||
make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=/absolute/path
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported lockfile formats: `uv.lock`, `requirements.txt`, `package-lock.json`,
|
|
||||||
`yarn.lock`, `Cargo.lock`, `.terraform.lock.hcl`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Infra-only repos
|
|
||||||
|
|
||||||
Repos with no lockfile (Ansible, shell scripts) can be registered for inventory
|
|
||||||
purposes. The SBOM gap is expected and can be left as-is. Terraform providers
|
|
||||||
are auto-detected via `.terraform.lock.hcl` when using `--scan`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*SBOM snapshots are replaced on each ingest — not appended. The last ingestion
|
|
||||||
timestamp is recorded on the managed_repo row.*
|
|
||||||
@@ -102,14 +102,28 @@ const repoRows = repos
|
|||||||
const integrating = !!integratingBySlug[r.slug];
|
const integrating = !!integratingBySlug[r.slug];
|
||||||
const doiEntry = doiBySlug[r.slug] ?? null;
|
const doiEntry = doiBySlug[r.slug] ?? null;
|
||||||
const doiTier = doiEntry?.tier ?? "none";
|
const doiTier = doiEntry?.tier ?? "none";
|
||||||
|
const category = r.category ?? "—";
|
||||||
|
const capList = r.capability_tags ?? [];
|
||||||
|
const stakeList = r.business_stake ?? [];
|
||||||
|
const capTags = capList.length
|
||||||
|
? capList.slice(0, 3).join(", ") + (capList.length > 3 ? "…" : "")
|
||||||
|
: "—";
|
||||||
|
const classifiedBy = r.classified_by ?? "—";
|
||||||
return {
|
return {
|
||||||
_id: r.id,
|
_id: r.id,
|
||||||
_domSlug: domSlug,
|
_domSlug: domSlug,
|
||||||
|
_category: category,
|
||||||
|
_capList: capList,
|
||||||
|
_stakeList: stakeList,
|
||||||
_hasSbom: hasSbom,
|
_hasSbom: hasSbom,
|
||||||
_integrating: integrating,
|
_integrating: integrating,
|
||||||
_doiTier: doiTier,
|
_doiTier: doiTier,
|
||||||
repo: r.slug,
|
repo: r.slug,
|
||||||
domain: domName,
|
domain: domName,
|
||||||
|
category: category,
|
||||||
|
capTags: capTags,
|
||||||
|
businessStake: stakeList.length ? stakeList.slice(0, 3).join(", ") : "—",
|
||||||
|
classifiedBy: classifiedBy,
|
||||||
status: integrating ? "⚙ integrating" : "ready",
|
status: integrating ? "⚙ integrating" : "ready",
|
||||||
path: r.local_path ?? "—",
|
path: r.local_path ?? "—",
|
||||||
sbom: hasSbom ? `✓ ${lastScan}` : "⚠ not ingested",
|
sbom: hasSbom ? `✓ ${lastScan}` : "⚠ not ingested",
|
||||||
@@ -153,9 +167,13 @@ display(html`<div class="kpi-row">
|
|||||||
<p class="big-num">${repoRows.length}</p>
|
<p class="big-num">${repoRows.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Domains</h3>
|
<h3>Market Domains</h3>
|
||||||
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
|
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Categories</h3>
|
||||||
|
<p class="big-num">${new Set(repoRows.map(r => r._category).filter(c => c !== "—")).size}</p>
|
||||||
|
</div>
|
||||||
<div class="card ${integratingCount > 0 ? 'card-integrating' : ''}">
|
<div class="card ${integratingCount > 0 ? 'card-integrating' : ''}">
|
||||||
<h3>Integrating</h3>
|
<h3>Integrating</h3>
|
||||||
<p class="big-num">${integratingCount}</p>
|
<p class="big-num">${integratingCount}</p>
|
||||||
@@ -240,6 +258,8 @@ if (domainBlocks.length === 0) {
|
|||||||
<table class="repo-table">
|
<table class="repo-table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Repo</th>
|
<th>Repo</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Capabilities</th>
|
||||||
<th>DoI Tier</th>
|
<th>DoI Tier</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>SBOM</th>
|
<th>SBOM</th>
|
||||||
@@ -249,6 +269,8 @@ if (domainBlocks.length === 0) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
|
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
|
||||||
<td class="repo-cell"><code>${r.repo}</code></td>
|
<td class="repo-cell"><code>${r.repo}</code></td>
|
||||||
|
<td>${r.category}</td>
|
||||||
|
<td class="path-cell" title=${r.capTags}>${r.capTags}</td>
|
||||||
<td>${_doiBadge(r._doiTier)}</td>
|
<td>${_doiBadge(r._doiTier)}</td>
|
||||||
<td>${r._integrating
|
<td>${r._integrating
|
||||||
? html`<span class="chip chip-integrating">⚙ integrating</span>`
|
? html`<span class="chip chip-integrating">⚙ integrating</span>`
|
||||||
@@ -266,25 +288,76 @@ if (domainBlocks.length === 0) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Portfolio by Category
|
||||||
|
|
||||||
|
```js
|
||||||
|
const byCategory = {};
|
||||||
|
for (const r of repoRows) {
|
||||||
|
const key = r._category === "—" ? "unclassified" : r._category;
|
||||||
|
(byCategory[key] = byCategory[key] ?? []).push(r);
|
||||||
|
}
|
||||||
|
const categoryBlocks = Object.entries(byCategory).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
if (categoryBlocks.length > 0) {
|
||||||
|
display(html`<h2 style="margin-top:2rem">Portfolio by Category</h2>
|
||||||
|
<div class="domain-list">
|
||||||
|
${categoryBlocks.map(([cat, rows]) => html`
|
||||||
|
<div class="domain-block">
|
||||||
|
<div class="domain-header">
|
||||||
|
<span class="domain-name">${cat}</span>
|
||||||
|
<span class="domain-chips">
|
||||||
|
<span class="chip chip-neutral">${rows.length} repo(s)</span>
|
||||||
|
<span class="chip chip-neutral">${new Set(rows.map(r => r._domSlug)).size} domain(s)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="repo-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Repo</th><th>Domain</th><th>Capabilities</th><th>Business stake</th><th>Classified</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${rows.map(r => html`<tr>
|
||||||
|
<td class="repo-cell"><code>${r.repo}</code></td>
|
||||||
|
<td>${r.domain}</td>
|
||||||
|
<td class="path-cell">${r.capTags}</td>
|
||||||
|
<td class="path-cell">${r.businessStake}</td>
|
||||||
|
<td>${r.classifiedBy}</td>
|
||||||
|
</tr>`)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## All Repos Table
|
## All Repos Table
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Domain", value: "all"});
|
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Market domain", value: "all"});
|
||||||
const doiFilter = Inputs.select(["all", "none", "core", "standard", "full"], {label: "DoI tier", value: "all"});
|
const categoryFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._category).filter(c => c !== "—")).values()], {label: "Category", value: "all"});
|
||||||
const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false});
|
const capabilityFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._capList)).values()].sort(), {label: "Capability", value: "all"});
|
||||||
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">${domainFilter}${doiFilter}${gapFilter}</div>`);
|
const stakeFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._stakeList)).values()].sort(), {label: "Business stake", value: "all"});
|
||||||
|
const doiFilter = Inputs.select(["all", "none", "core", "standard", "full"], {label: "DoI tier", value: "all"});
|
||||||
|
const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false});
|
||||||
|
display(html`<div class="filter-bar">${domainFilter}${categoryFilter}${capabilityFilter}${stakeFilter}${doiFilter}${gapFilter}</div>`);
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const filteredRows = repoRows.filter(r =>
|
const filteredRows = repoRows.filter(r =>
|
||||||
(domainFilter.value === "all" || r._domSlug === domainFilter.value) &&
|
(domainFilter.value === "all" || r._domSlug === domainFilter.value) &&
|
||||||
(doiFilter.value === "all" || r._doiTier === doiFilter.value) &&
|
(categoryFilter.value === "all" || r._category === categoryFilter.value) &&
|
||||||
|
(capabilityFilter.value === "all" || r._capList.includes(capabilityFilter.value)) &&
|
||||||
|
(stakeFilter.value === "all" || r._stakeList.includes(stakeFilter.value)) &&
|
||||||
|
(doiFilter.value === "all" || r._doiTier === doiFilter.value) &&
|
||||||
(!gapFilter.value || !r._hasSbom)
|
(!gapFilter.value || !r._hasSbom)
|
||||||
);
|
);
|
||||||
|
|
||||||
display(Inputs.table(filteredRows.map(r => ({
|
display(Inputs.table(filteredRows.map(r => ({
|
||||||
Repo: r.repo,
|
Repo: r.repo,
|
||||||
Domain: r.domain,
|
Domain: r.domain,
|
||||||
|
Category: r.category,
|
||||||
|
Capabilities: r.capTags,
|
||||||
|
"Business stake": r.businessStake,
|
||||||
|
Classified: r.classifiedBy,
|
||||||
"DoI Tier": DOI_TIER_LABEL[r._doiTier] ?? r._doiTier,
|
"DoI Tier": DOI_TIER_LABEL[r._doiTier] ?? r._doiTier,
|
||||||
Status: r.status,
|
Status: r.status,
|
||||||
SBOM: r.sbom,
|
SBOM: r.sbom,
|
||||||
|
|||||||
@@ -20,6 +20,29 @@ Do not use them as a substitute for formal work definition inside the domain rep
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Workplan terminology (STATE-WP-0065)
|
||||||
|
|
||||||
|
**Preferred terms:** workplan, `workplan_id`, `/workplans/…`
|
||||||
|
|
||||||
|
**Legacy compatibility:** `workstream`, `workstream_id`, `/workstreams/…`, and
|
||||||
|
`create_workstream` / `update_workstream` MCP tools remain available as aliases.
|
||||||
|
They call the same implementation as the workplan-named tools and endpoints.
|
||||||
|
|
||||||
|
| Preferred (workplan) | Legacy alias (workstream) |
|
||||||
|
|---|---|
|
||||||
|
| `create_workplan(repo_id, …)` | `create_workstream(repo_id, …)` |
|
||||||
|
| `update_workplan` / `update_workplan_status` | `update_workstream` / `update_workstream_status` |
|
||||||
|
| `list_workplans` | `list_workstreams` |
|
||||||
|
| `create_workplan_dependency` | `create_dependency` |
|
||||||
|
| `POST /workplans/` | `POST /workstreams/` (deprecated headers) |
|
||||||
|
| `workplan_id` query/body field | `workstream_id` (accepted alias) |
|
||||||
|
|
||||||
|
Repo classification filters: `list_repos_by_classification(category?, domain?,
|
||||||
|
capability_tag?, business_stake?)` and extended `list_domain_repos(...)` query
|
||||||
|
params use `GET /repos/` classification spine fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MCP/REST Parity and Failure Handling
|
## MCP/REST Parity and Failure Handling
|
||||||
|
|
||||||
The MCP server is a thin stateless HTTP client over the FastAPI service. On
|
The MCP server is a thin stateless HTTP client over the FastAPI service. On
|
||||||
@@ -28,6 +51,10 @@ endpoint they wrap:
|
|||||||
|
|
||||||
| MCP tool | REST endpoint |
|
| MCP tool | REST endpoint |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `create_workplan(...)` | `POST /workplans/` |
|
||||||
|
| `list_workplans(...)` | `GET /workplans/` |
|
||||||
|
| `update_workplan_status(...)` | `PATCH /workplans/{workplan_id}` |
|
||||||
|
| `list_repos_by_classification(...)` | `GET /repos/?category=…` |
|
||||||
| `create_workstream(...)` | `POST /workstreams/` |
|
| `create_workstream(...)` | `POST /workstreams/` |
|
||||||
| `create_task(...)` | `POST /tasks/` |
|
| `create_task(...)` | `POST /tasks/` |
|
||||||
| `update_task_status(...)` | `PATCH /tasks/{task_id}` |
|
| `update_task_status(...)` | `PATCH /tasks/{task_id}` |
|
||||||
|
|||||||
@@ -358,18 +358,29 @@ def create_topic(slug: str, title: str, domain: str, description: str | None = N
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_tasks(workstream_id: str, status: str | None = None) -> str:
|
def list_tasks(
|
||||||
"""List all tasks in a workstream, optionally filtered by status.
|
workplan_id: str | None = None,
|
||||||
|
workstream_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""List all tasks in a workplan, optionally filtered by status.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workstream_id: UUID of the workstream (required).
|
workplan_id: UUID of the workplan (preferred).
|
||||||
|
workstream_id: legacy alias for workplan_id.
|
||||||
status: Optional filter — wait | todo | progress | done | cancel.
|
status: Optional filter — wait | todo | progress | done | cancel.
|
||||||
|
|
||||||
Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every
|
Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every
|
||||||
matching task. Use this to look up task UUIDs before calling update_task_status,
|
matching task. Use this to look up task UUIDs before calling update_task_status,
|
||||||
or to check which tasks from a workplan file are already synced to the DB.
|
or to check which tasks from a workplan file are already synced to the DB.
|
||||||
"""
|
"""
|
||||||
return json.dumps(_get("/tasks", {"workstream_id": workstream_id, "status": status}), indent=2)
|
parent_id = workplan_id or workstream_id
|
||||||
|
if not parent_id:
|
||||||
|
return _json_result(_mcp_error("list_tasks", "workplan_id is required"))
|
||||||
|
return json.dumps(
|
||||||
|
_get("/tasks", {"workplan_id": parent_id, "workstream_id": parent_id, "status": status}),
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -455,37 +466,30 @@ def advance_workstation(entity_type: str, entity_id: str, target_workstation: st
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Mutate tools
|
# Workplan helpers (preferred) + legacy workstream aliases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@mcp.tool()
|
def _workplan_id_from_response(payload: dict[str, Any]) -> str | None:
|
||||||
def create_workstream(
|
return payload.get("workplan_id") or payload.get("workstream_id") or payload.get("id")
|
||||||
topic_id: str,
|
|
||||||
|
|
||||||
|
def _create_workplan_impl(
|
||||||
|
*,
|
||||||
|
repo_id: str,
|
||||||
title: str,
|
title: str,
|
||||||
|
topic_id: str | None = None,
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
due_date: str | None = None,
|
due_date: str | None = None,
|
||||||
repo_id: str | None = None,
|
|
||||||
planning_priority: str | None = None,
|
planning_priority: str | None = None,
|
||||||
planning_order: int | None = None,
|
planning_order: int | None = None,
|
||||||
|
tool_name: str = "create_workplan",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a new workstream under a topic and emit a progress_event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
topic_id: UUID of the parent topic
|
|
||||||
title: workstream title
|
|
||||||
slug: URL-friendly identifier (auto-generated from title if omitted)
|
|
||||||
description: optional longer description
|
|
||||||
owner: optional owner name
|
|
||||||
due_date: optional ISO date string (YYYY-MM-DD)
|
|
||||||
repo_id: UUID of the owning repository (GEMS primary; strongly recommended per ADR-001)
|
|
||||||
planning_priority: optional planning priority (critical/high/medium/low or repo-local value)
|
|
||||||
planning_order: optional numeric ordering hint inside a repo/domain
|
|
||||||
"""
|
|
||||||
if not slug:
|
if not slug:
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
||||||
ws = _post("/workstreams", {
|
wp = _post("/workplans", {
|
||||||
|
"repo_id": repo_id,
|
||||||
"topic_id": topic_id,
|
"topic_id": topic_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
@@ -493,30 +497,147 @@ def create_workstream(
|
|||||||
"owner": owner,
|
"owner": owner,
|
||||||
"due_date": due_date,
|
"due_date": due_date,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"repo_id": repo_id,
|
|
||||||
"planning_priority": planning_priority,
|
"planning_priority": planning_priority,
|
||||||
"planning_order": planning_order,
|
"planning_order": planning_order,
|
||||||
})
|
})
|
||||||
if error := _response_error("create_workstream", ws, ("id",)):
|
if error := _response_error(tool_name, wp, ("id",)):
|
||||||
return _json_result(error)
|
return _json_result(error)
|
||||||
|
|
||||||
progress_error = _emit_progress_event("create_workstream", ws, {
|
progress_error = _emit_progress_event(tool_name, wp, {
|
||||||
"topic_id": topic_id,
|
"topic_id": topic_id,
|
||||||
"workstream_id": ws["id"],
|
"workplan_id": wp["id"],
|
||||||
"event_type": "workstream_created",
|
"workstream_id": wp["id"],
|
||||||
"summary": f"Workstream created: {title}",
|
"event_type": "workplan_created",
|
||||||
|
"summary": f"Workplan created: {title}",
|
||||||
"author": "custodian",
|
"author": "custodian",
|
||||||
"detail": {"owner": owner, "slug": slug},
|
"detail": {"owner": owner, "slug": slug, "repo_id": repo_id},
|
||||||
})
|
})
|
||||||
if progress_error:
|
if progress_error:
|
||||||
return _json_result(progress_error)
|
return _json_result(progress_error)
|
||||||
return _json_result(ws)
|
return _json_result(wp)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_workplan_status_impl(workplan_id: str, status: str, *, tool_name: str) -> str:
|
||||||
|
wp = _patch(f"/workplans/{workplan_id}", {"status": status})
|
||||||
|
if error := _response_error(tool_name, wp, ("id", "title")):
|
||||||
|
return _json_result(error)
|
||||||
|
|
||||||
|
progress_error = _emit_progress_event(tool_name, wp, {
|
||||||
|
"workplan_id": workplan_id,
|
||||||
|
"workstream_id": workplan_id,
|
||||||
|
"topic_id": wp.get("topic_id"),
|
||||||
|
"event_type": "workplan_status_changed",
|
||||||
|
"summary": f"Workplan status → {status}: {wp['title']}",
|
||||||
|
"author": "custodian",
|
||||||
|
})
|
||||||
|
if progress_error:
|
||||||
|
return _json_result(progress_error)
|
||||||
|
return _json_result(wp)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_workplan_impl(
|
||||||
|
workplan_id: str,
|
||||||
|
*,
|
||||||
|
title: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
due_date: str | None = None,
|
||||||
|
repo_goal_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if title is not None:
|
||||||
|
payload["title"] = title
|
||||||
|
if description is not None:
|
||||||
|
payload["description"] = description
|
||||||
|
if owner is not None:
|
||||||
|
payload["owner"] = owner
|
||||||
|
if due_date is not None:
|
||||||
|
payload["due_date"] = due_date
|
||||||
|
if status is not None:
|
||||||
|
payload["status"] = status
|
||||||
|
if repo_goal_id is not None:
|
||||||
|
payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None
|
||||||
|
return _json_result(_patch(f"/workplans/{workplan_id}", payload))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mutate tools
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_workplan(
|
||||||
|
repo_id: str,
|
||||||
|
title: str,
|
||||||
|
topic_id: str | None = None,
|
||||||
|
slug: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
due_date: str | None = None,
|
||||||
|
planning_priority: str | None = None,
|
||||||
|
planning_order: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create a new repo-anchored workplan and emit a progress_event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_id: UUID of the owning repository (required)
|
||||||
|
title: workplan title
|
||||||
|
topic_id: optional topic UUID for cross-repo tagging
|
||||||
|
slug: URL-friendly identifier (auto-generated from title if omitted)
|
||||||
|
description: optional longer description
|
||||||
|
owner: optional owner name
|
||||||
|
due_date: optional ISO date string (YYYY-MM-DD)
|
||||||
|
planning_priority: optional planning priority (critical/high/medium/low or repo-local value)
|
||||||
|
planning_order: optional numeric ordering hint inside a repo
|
||||||
|
"""
|
||||||
|
return _create_workplan_impl(
|
||||||
|
repo_id=repo_id,
|
||||||
|
title=title,
|
||||||
|
topic_id=topic_id,
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
owner=owner,
|
||||||
|
due_date=due_date,
|
||||||
|
planning_priority=planning_priority,
|
||||||
|
planning_order=planning_order,
|
||||||
|
tool_name="create_workplan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_workstream(
|
||||||
|
title: str,
|
||||||
|
repo_id: str | None = None,
|
||||||
|
topic_id: str | None = None,
|
||||||
|
slug: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
due_date: str | None = None,
|
||||||
|
planning_priority: str | None = None,
|
||||||
|
planning_order: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Legacy alias for create_workplan — prefer create_workplan(repo_id=...)."""
|
||||||
|
if not repo_id:
|
||||||
|
return _json_result(_mcp_error("create_workstream", "repo_id is required"))
|
||||||
|
return _create_workplan_impl(
|
||||||
|
repo_id=repo_id,
|
||||||
|
title=title,
|
||||||
|
topic_id=topic_id,
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
owner=owner,
|
||||||
|
due_date=due_date,
|
||||||
|
planning_priority=planning_priority,
|
||||||
|
planning_order=planning_order,
|
||||||
|
tool_name="create_workstream",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_task(
|
def create_task(
|
||||||
workstream_id: str,
|
workplan_id: str | None = None,
|
||||||
title: str,
|
workstream_id: str | None = None,
|
||||||
|
title: str = "",
|
||||||
priority: str = "medium",
|
priority: str = "medium",
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
assignee: str | None = None,
|
assignee: str | None = None,
|
||||||
@@ -525,15 +646,19 @@ def create_task(
|
|||||||
"""Create a new task and emit a progress_event.
|
"""Create a new task and emit a progress_event.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workstream_id: UUID of the parent workstream
|
workplan_id: UUID of the parent workplan (preferred)
|
||||||
|
workstream_id: legacy alias for workplan_id
|
||||||
title: task title
|
title: task title
|
||||||
priority: low | medium | high | critical
|
priority: low | medium | high | critical
|
||||||
description: optional longer description
|
description: optional longer description
|
||||||
assignee: optional assignee name
|
assignee: optional assignee name
|
||||||
due_date: optional ISO date string (YYYY-MM-DD)
|
due_date: optional ISO date string (YYYY-MM-DD)
|
||||||
"""
|
"""
|
||||||
|
parent_id = workplan_id or workstream_id
|
||||||
|
if not parent_id:
|
||||||
|
return _json_result(_mcp_error("create_task", "workplan_id is required"))
|
||||||
task = _post("/tasks", {
|
task = _post("/tasks", {
|
||||||
"workstream_id": workstream_id,
|
"workplan_id": parent_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"priority": priority,
|
"priority": priority,
|
||||||
"description": description,
|
"description": description,
|
||||||
@@ -544,7 +669,8 @@ def create_task(
|
|||||||
return _json_result(error)
|
return _json_result(error)
|
||||||
|
|
||||||
progress_error = _emit_progress_event("create_task", task, {
|
progress_error = _emit_progress_event("create_task", task, {
|
||||||
"workstream_id": workstream_id,
|
"workplan_id": parent_id,
|
||||||
|
"workstream_id": parent_id,
|
||||||
"task_id": task["id"],
|
"task_id": task["id"],
|
||||||
"event_type": "task_created",
|
"event_type": "task_created",
|
||||||
"summary": f"Task created: {title}",
|
"summary": f"Task created: {title}",
|
||||||
@@ -865,27 +991,81 @@ def add_progress_event(
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def update_workstream_status(workstream_id: str, status: str) -> str:
|
def list_workplans(
|
||||||
"""Update a workstream's status.
|
repo_id: str | None = None,
|
||||||
|
topic_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
slug: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""List workplans with optional filters."""
|
||||||
|
return json.dumps(
|
||||||
|
_get("/workplans", {
|
||||||
|
"repo_id": repo_id,
|
||||||
|
"topic_id": topic_id,
|
||||||
|
"status": status,
|
||||||
|
"owner": owner,
|
||||||
|
"slug": slug,
|
||||||
|
}),
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_workstreams(
|
||||||
|
topic_id: str | None = None,
|
||||||
|
repo_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
slug: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Legacy alias for list_workplans."""
|
||||||
|
return list_workplans(
|
||||||
|
repo_id=repo_id,
|
||||||
|
topic_id=topic_id,
|
||||||
|
status=status,
|
||||||
|
owner=owner,
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def update_workplan_status(workplan_id: str, status: str) -> str:
|
||||||
|
"""Update a workplan's status.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workstream_id: UUID of the workstream
|
workplan_id: UUID of the workplan
|
||||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
"""
|
"""
|
||||||
ws = _patch(f"/workstreams/{workstream_id}", {"status": status})
|
return _update_workplan_status_impl(workplan_id, status, tool_name="update_workplan_status")
|
||||||
if error := _response_error("update_workstream_status", ws, ("id", "title")):
|
|
||||||
return _json_result(error)
|
|
||||||
|
|
||||||
progress_error = _emit_progress_event("update_workstream_status", ws, {
|
|
||||||
"workstream_id": workstream_id,
|
@mcp.tool()
|
||||||
"topic_id": ws.get("topic_id"),
|
def update_workstream_status(workstream_id: str, status: str) -> str:
|
||||||
"event_type": "workstream_status_changed",
|
"""Legacy alias for update_workplan_status."""
|
||||||
"summary": f"Workstream status → {status}: {ws['title']}",
|
return _update_workplan_status_impl(workstream_id, status, tool_name="update_workstream_status")
|
||||||
"author": "custodian",
|
|
||||||
})
|
|
||||||
if progress_error:
|
@mcp.tool()
|
||||||
return _json_result(progress_error)
|
def update_workplan(
|
||||||
return _json_result(ws)
|
workplan_id: str,
|
||||||
|
title: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
due_date: str | None = None,
|
||||||
|
repo_goal_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Update fields on an existing workplan."""
|
||||||
|
return _update_workplan_impl(
|
||||||
|
workplan_id,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
owner=owner,
|
||||||
|
due_date=due_date,
|
||||||
|
repo_goal_id=repo_goal_id,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -898,32 +1078,16 @@ def update_workstream(
|
|||||||
repo_goal_id: str | None = None,
|
repo_goal_id: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Update fields on an existing workstream.
|
"""Legacy alias for update_workplan."""
|
||||||
|
return _update_workplan_impl(
|
||||||
Args:
|
workstream_id,
|
||||||
workstream_id: UUID of the workstream
|
title=title,
|
||||||
title: new title (optional)
|
description=description,
|
||||||
description: new description (optional)
|
owner=owner,
|
||||||
owner: new owner (optional)
|
due_date=due_date,
|
||||||
due_date: ISO date string YYYY-MM-DD (optional)
|
repo_goal_id=repo_goal_id,
|
||||||
repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear)
|
status=status,
|
||||||
status: proposed | ready | active | blocked | backlog | finished | archived (optional)
|
)
|
||||||
"""
|
|
||||||
payload: dict = {}
|
|
||||||
if title is not None:
|
|
||||||
payload["title"] = title
|
|
||||||
if description is not None:
|
|
||||||
payload["description"] = description
|
|
||||||
if owner is not None:
|
|
||||||
payload["owner"] = owner
|
|
||||||
if due_date is not None:
|
|
||||||
payload["due_date"] = due_date
|
|
||||||
if status is not None:
|
|
||||||
payload["status"] = status
|
|
||||||
if repo_goal_id is not None:
|
|
||||||
payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None
|
|
||||||
ws = _patch(f"/workstreams/{workstream_id}", payload)
|
|
||||||
return json.dumps(ws, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -951,6 +1115,41 @@ def get_next_steps() -> str:
|
|||||||
# Dependency graph tools (S1.4)
|
# Dependency graph tools (S1.4)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _create_dependency_impl(
|
||||||
|
*,
|
||||||
|
from_workplan_id: str,
|
||||||
|
to_workplan_id: str | None = None,
|
||||||
|
to_task_id: str | None = None,
|
||||||
|
relationship_type: str = "blocks",
|
||||||
|
description: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
dep = _post(f"/workplans/{from_workplan_id}/dependencies", {
|
||||||
|
"to_workplan_id": to_workplan_id,
|
||||||
|
"to_task_id": to_task_id,
|
||||||
|
"relationship_type": relationship_type,
|
||||||
|
"description": description,
|
||||||
|
})
|
||||||
|
return json.dumps(dep, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_workplan_dependency(
|
||||||
|
from_workplan_id: str,
|
||||||
|
to_workplan_id: str | None = None,
|
||||||
|
to_task_id: str | None = None,
|
||||||
|
relationship_type: str = "blocks",
|
||||||
|
description: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Record that one workplan depends on another workplan or task."""
|
||||||
|
return _create_dependency_impl(
|
||||||
|
from_workplan_id=from_workplan_id,
|
||||||
|
to_workplan_id=to_workplan_id,
|
||||||
|
to_task_id=to_task_id,
|
||||||
|
relationship_type=relationship_type,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_dependency(
|
def create_dependency(
|
||||||
from_workstream_id: str,
|
from_workstream_id: str,
|
||||||
@@ -959,25 +1158,14 @@ def create_dependency(
|
|||||||
relationship_type: str = "blocks",
|
relationship_type: str = "blocks",
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Record that one workstream depends on another workstream or task.
|
"""Legacy alias for create_workplan_dependency."""
|
||||||
|
return _create_dependency_impl(
|
||||||
Semantics: from_workstream cannot fully proceed until the target reaches
|
from_workplan_id=from_workstream_id,
|
||||||
a satisfactory state. Provide exactly one of to_workstream_id or to_task_id.
|
to_workplan_id=to_workstream_id,
|
||||||
|
to_task_id=to_task_id,
|
||||||
Args:
|
relationship_type=relationship_type,
|
||||||
from_workstream_id: UUID of the workstream that has the dependency
|
description=description,
|
||||||
to_workstream_id: UUID of the workstream it depends on
|
)
|
||||||
to_task_id: UUID of the task it depends on
|
|
||||||
relationship_type: blocks | starts_after | informs | soft_dependency
|
|
||||||
description: optional human-readable explanation of the dependency
|
|
||||||
"""
|
|
||||||
dep = _post(f"/workstreams/{from_workstream_id}/dependencies", {
|
|
||||||
"to_workstream_id": to_workstream_id,
|
|
||||||
"to_task_id": to_task_id,
|
|
||||||
"relationship_type": relationship_type,
|
|
||||||
"description": description,
|
|
||||||
})
|
|
||||||
return json.dumps(dep, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -990,9 +1178,15 @@ def list_dependencies(workstream_id: str) -> str:
|
|||||||
Args:
|
Args:
|
||||||
workstream_id: UUID of the workstream to inspect
|
workstream_id: UUID of the workstream to inspect
|
||||||
"""
|
"""
|
||||||
edges = _get(f"/workstreams/{workstream_id}/dependencies")
|
edges = _get(f"/workplans/{workstream_id}/dependencies")
|
||||||
depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id]
|
depends_on = [
|
||||||
blocks = [e for e in edges if e.get("to_workstream_id") == workstream_id]
|
e for e in edges
|
||||||
|
if e.get("from_workplan_id", e.get("from_workstream_id")) == workstream_id
|
||||||
|
]
|
||||||
|
blocks = [
|
||||||
|
e for e in edges
|
||||||
|
if e.get("to_workplan_id", e.get("to_workstream_id")) == workstream_id
|
||||||
|
]
|
||||||
return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)
|
return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
@@ -1227,13 +1421,48 @@ def archive_domain(slug: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_domain_repos(domain_slug: str) -> str:
|
def list_domain_repos(
|
||||||
"""List all repositories registered under a domain.
|
domain_slug: str,
|
||||||
|
category: str | None = None,
|
||||||
|
capability_tag: str | None = None,
|
||||||
|
business_stake: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""List repositories registered under a domain, with optional classification filters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain_slug: Domain slug to filter by
|
domain_slug: Domain slug to filter by
|
||||||
|
category: optional repo classification category
|
||||||
|
capability_tag: optional capability tag filter
|
||||||
|
business_stake: optional business stake filter
|
||||||
"""
|
"""
|
||||||
return json.dumps(_get("/repos", {"domain": domain_slug}), indent=2)
|
return json.dumps(
|
||||||
|
_get("/repos", {
|
||||||
|
"domain": domain_slug,
|
||||||
|
"category": category,
|
||||||
|
"capability_tag": capability_tag,
|
||||||
|
"business_stake": business_stake,
|
||||||
|
}),
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_repos_by_classification(
|
||||||
|
category: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
capability_tag: str | None = None,
|
||||||
|
business_stake: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""List repos filtered by classification spine fields."""
|
||||||
|
return json.dumps(
|
||||||
|
_get("/repos", {
|
||||||
|
"domain": domain,
|
||||||
|
"category": category,
|
||||||
|
"capability_tag": capability_tag,
|
||||||
|
"business_stake": business_stake,
|
||||||
|
}),
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -1275,6 +1504,60 @@ def register_repo(
|
|||||||
return json.dumps(repo, indent=2)
|
return json.dumps(repo, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def register_repo_from_classification(
|
||||||
|
repo_slug: str,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Register or update a repo from its committed ``.repo-classification.yaml``.
|
||||||
|
|
||||||
|
Reads the classification file from the repo's local checkout (this host's
|
||||||
|
registered path), validates against the canon allowed-values, and upserts the
|
||||||
|
``managed_repo`` row including market-domain assignment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_slug: Registered repo slug (e.g. 'state-hub', 'the-custodian').
|
||||||
|
dry_run: If True, report what would change without writing.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
script = Path(__file__).parent.parent / "scripts" / "register_from_classification.py"
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
str(script),
|
||||||
|
"--slug",
|
||||||
|
repo_slug,
|
||||||
|
"--json",
|
||||||
|
]
|
||||||
|
if dry_run:
|
||||||
|
cmd.append("--dry-run")
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
try:
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return (
|
||||||
|
f"register-from-classification failed (exit {result.returncode}):\n"
|
||||||
|
f"{result.stderr or result.stdout or '(no output)'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = data.get("summary", {})
|
||||||
|
lines = [
|
||||||
|
f"register-from-classification: {repo_slug}",
|
||||||
|
(
|
||||||
|
f"registered={summary.get('registered', 0)} "
|
||||||
|
f"updated={summary.get('updated', 0)} "
|
||||||
|
f"skipped={summary.get('skipped', 0)} "
|
||||||
|
f"invalid={summary.get('invalid', 0)}"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for row in data.get("results", []):
|
||||||
|
lines.append(f" [{row.get('outcome')}] {row.get('detail', '')}")
|
||||||
|
if result.returncode != 0:
|
||||||
|
lines.append("(completed with invalid rows)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def update_repo_path(repo_slug: str, path: str, host: str | None = None) -> str:
|
def update_repo_path(repo_slug: str, path: str, host: str | None = None) -> str:
|
||||||
"""Register or update the local filesystem path for a repo on a specific host.
|
"""Register or update the local filesystem path for a repo on a specific host.
|
||||||
|
|||||||
@@ -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-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph
|
||||||
C-22 task-description-drift WARN Yes Task description/content differs between file and DB
|
C-22 task-description-drift WARN Yes Task description/content differs between file and DB
|
||||||
C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is progress or wait
|
C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is progress or wait
|
||||||
|
C-24 repo-classification-missing WARN No Registered repo lacks a valid .repo-classification.yaml on disk
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
|
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
|
||||||
@@ -42,7 +43,7 @@ Exit codes (--remote --all scheduled sweep):
|
|||||||
1 — automation error: API unreachable, repo list fetch failed, C-00 on
|
1 — automation error: API unreachable, repo list fetch failed, C-00 on
|
||||||
any repo, or other infrastructure fault that prevented a full run
|
any repo, or other infrastructure fault that prevented a full run
|
||||||
|
|
||||||
Assessment failures (C-01..C-23 except C-00) are repo hygiene gaps recorded
|
Assessment failures (C-01..C-24 except C-00) are repo hygiene gaps recorded
|
||||||
in the sweep report for later improvement. They do not fail the scheduler.
|
in the sweep report for later improvement. They do not fail the scheduler.
|
||||||
|
|
||||||
Agent/operator Make wrappers normalize exit code 2 to shell success while
|
Agent/operator Make wrappers normalize exit code 2 to shell success while
|
||||||
@@ -78,6 +79,11 @@ from api.workplan_status import ( # noqa: E402
|
|||||||
normalize_workstream_status as _normalize_workstream_status,
|
normalize_workstream_status as _normalize_workstream_status,
|
||||||
ready_review_status,
|
ready_review_status,
|
||||||
)
|
)
|
||||||
|
from api.classification import ( # noqa: E402
|
||||||
|
CLASSIFICATION_FILENAME,
|
||||||
|
load_classification_file,
|
||||||
|
resolve_topic_domain_slug,
|
||||||
|
)
|
||||||
from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402
|
from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402
|
||||||
from api.task_status import ( # noqa: E402
|
from api.task_status import ( # noqa: E402
|
||||||
CANONICAL_TASK_STATUSES,
|
CANONICAL_TASK_STATUSES,
|
||||||
@@ -713,6 +719,31 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
|
|
||||||
repo_dir = Path(repo_path)
|
repo_dir = Path(repo_path)
|
||||||
workplans_dir = repo_dir / "workplans"
|
workplans_dir = repo_dir / "workplans"
|
||||||
|
repo_market_domain = str(repo.get("domain_slug") or "").strip()
|
||||||
|
|
||||||
|
# C-24: repo classification file missing or invalid (always WARN — migration rows too)
|
||||||
|
class_data, class_errors, class_warnings = load_classification_file(repo_dir)
|
||||||
|
if class_data is None:
|
||||||
|
classified_by = str(repo.get("classified_by") or "").strip()
|
||||||
|
if class_errors:
|
||||||
|
detail = "; ".join(class_errors)
|
||||||
|
else:
|
||||||
|
detail = f"{CLASSIFICATION_FILENAME} missing on disk"
|
||||||
|
if classified_by == "migration":
|
||||||
|
detail = f"{detail} (DB row is migration-derived — commit a human-reviewed file when ready)"
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-24",
|
||||||
|
message=f"Repo classification gap: {detail}",
|
||||||
|
fixable=False,
|
||||||
|
)
|
||||||
|
for warning in class_warnings:
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-24",
|
||||||
|
message=f"Repo classification advisory: {warning}",
|
||||||
|
fixable=False,
|
||||||
|
)
|
||||||
|
|
||||||
# C-01: workplans/ directory missing
|
# C-01: workplans/ directory missing
|
||||||
if not workplans_dir.is_dir():
|
if not workplans_dir.is_dir():
|
||||||
@@ -804,6 +835,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
"body": body,
|
"body": body,
|
||||||
"repo_id": repo_id,
|
"repo_id": repo_id,
|
||||||
"domain": file_domain,
|
"domain": file_domain,
|
||||||
|
"repo_market_domain": repo_market_domain,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -1708,6 +1740,7 @@ def fix_repo(
|
|||||||
wp_file = Path(ctx["wp_file"])
|
wp_file = Path(ctx["wp_file"])
|
||||||
meta = ctx["meta"]
|
meta = ctx["meta"]
|
||||||
domain = ctx["domain"]
|
domain = ctx["domain"]
|
||||||
|
repo_market_domain = str(ctx.get("repo_market_domain") or "").strip()
|
||||||
repo_id_val = ctx["repo_id"]
|
repo_id_val = ctx["repo_id"]
|
||||||
body = ctx.get("body", "")
|
body = ctx.get("body", "")
|
||||||
wp_id = str(meta.get("id", "")).strip()
|
wp_id = str(meta.get("id", "")).strip()
|
||||||
@@ -1717,17 +1750,23 @@ def fix_repo(
|
|||||||
if status not in VALID_WP_STATUSES:
|
if status not in VALID_WP_STATUSES:
|
||||||
status = "active"
|
status = "active"
|
||||||
|
|
||||||
# Find topic_id for this domain
|
# Find topic_id — workplan frontmatter may still use legacy
|
||||||
|
# coordination slugs (e.g. custodian); map to market domain first.
|
||||||
|
topic_domain = resolve_topic_domain_slug(
|
||||||
|
domain,
|
||||||
|
repo_market_domain=repo_market_domain or None,
|
||||||
|
)
|
||||||
topics = _api_get(api_base, "/topics")
|
topics = _api_get(api_base, "/topics")
|
||||||
topic_id = None
|
topic_id = None
|
||||||
if isinstance(topics, list):
|
if isinstance(topics, list):
|
||||||
for t in topics:
|
for t in topics:
|
||||||
if t.get("domain_slug") == domain:
|
if t.get("domain_slug") == topic_domain:
|
||||||
topic_id = t["id"]
|
topic_id = t["id"]
|
||||||
break
|
break
|
||||||
if topic_id is None:
|
if topic_id is None:
|
||||||
report.fixes_applied.append(
|
report.fixes_applied.append(
|
||||||
f"C-06 SKIP {wp_id}: no topic found for domain '{domain}'"
|
f"C-06 SKIP {wp_id}: no topic found for domain "
|
||||||
|
f"'{topic_domain}' (workplan domain={domain!r})"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
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:
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||||
yield ac
|
yield ac
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared entity helpers (workplan-first; legacy workstream names retained)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_test_domain(client, slug="infotech", name="Infotech"):
|
||||||
|
r = await client.post("/domains/", json={"slug": slug, "name": name})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_test_topic(client, domain_slug="infotech", slug="testtopic", title="Test Topic"):
|
||||||
|
r = await client.post("/topics/", json={
|
||||||
|
"slug": slug, "title": title, "domain": domain_slug,
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_test_repo(client, domain_slug="infotech", slug="test-repo", **extra):
|
||||||
|
payload = {
|
||||||
|
"domain_slug": domain_slug,
|
||||||
|
"slug": slug,
|
||||||
|
"name": "Test Repo",
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
r = await client.post("/repos/", json=payload)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_test_workplan(
|
||||||
|
client,
|
||||||
|
repo_id,
|
||||||
|
topic_id=None,
|
||||||
|
slug="test-wp",
|
||||||
|
title="Test Workplan",
|
||||||
|
status="active",
|
||||||
|
**extra,
|
||||||
|
):
|
||||||
|
payload = {"repo_id": repo_id, "slug": slug, "title": title, "status": status, **extra}
|
||||||
|
if topic_id is not None:
|
||||||
|
payload["topic_id"] = topic_id
|
||||||
|
r = await client.post("/workplans/", json=payload)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_test_workstream(client, topic_id=None, repo_id=None, slug="test-wp", **kwargs):
|
||||||
|
"""Legacy helper name — creates a repo-anchored workplan."""
|
||||||
|
if repo_id is None:
|
||||||
|
domain = await create_test_domain(client)
|
||||||
|
if topic_id is None:
|
||||||
|
topic = await create_test_topic(client, domain_slug=domain["slug"])
|
||||||
|
topic_id = topic["id"]
|
||||||
|
repo = await create_test_repo(client, domain_slug=domain["slug"], slug=f"{slug}-repo")
|
||||||
|
repo_id = repo["id"]
|
||||||
|
return await create_test_workplan(client, repo_id=repo_id, topic_id=topic_id, slug=slug, **kwargs)
|
||||||
|
|||||||
@@ -27,17 +27,19 @@ async def _create_topic(client, domain_slug="testdomain"):
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_workstream(client, topic_id):
|
from tests.conftest import create_test_repo, create_test_workplan
|
||||||
r = await client.post("/workstreams/", json={
|
|
||||||
"topic_id": topic_id, "slug": "test-ws", "title": "Test WS",
|
|
||||||
})
|
|
||||||
assert r.status_code == 201, r.text
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_task(client, workstream_id, title="Test task", status="wait"):
|
async def _create_workstream(client, topic_id, domain_slug="custodian"):
|
||||||
|
repo = await create_test_repo(client, domain_slug=domain_slug, slug="test-repo")
|
||||||
|
return await create_test_workplan(
|
||||||
|
client, repo_id=repo["id"], topic_id=topic_id, slug="test-ws", title="Test WS",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_task(client, workplan_id, title="Test task", status="wait"):
|
||||||
r = await client.post("/tasks/", json={
|
r = await client.post("/tasks/", json={
|
||||||
"workstream_id": workstream_id, "title": title,
|
"workplan_id": workplan_id, "title": title,
|
||||||
})
|
})
|
||||||
assert r.status_code == 201, r.text
|
assert r.status_code == 201, r.text
|
||||||
task = r.json()
|
task = r.json()
|
||||||
|
|||||||
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
|
# Make scripts/ importable without installing
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from api.classification import resolve_topic_domain_slug
|
||||||
from consistency_check import (
|
from consistency_check import (
|
||||||
ConsistencyReport,
|
ConsistencyReport,
|
||||||
Issue,
|
Issue,
|
||||||
@@ -54,6 +55,15 @@ from api.workplan_status import ready_review_status
|
|||||||
# for backward compat; their canonical implementations live in repo_sync.py.
|
# for backward compat; their canonical implementations live in repo_sync.py.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# domain mapping (STATE-WP-0065 P4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestResolveTopicDomainSlug:
|
||||||
|
def test_workplan_custodian_maps_to_infotech(self):
|
||||||
|
assert resolve_topic_domain_slug("custodian", repo_market_domain="infotech") == "infotech"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# parse_frontmatter
|
# parse_frontmatter
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -372,7 +382,7 @@ class TestRenderText:
|
|||||||
r.add(severity="WARN", check_id="C-04", message="w")
|
r.add(severity="WARN", check_id="C-04", message="w")
|
||||||
r.add(severity="INFO", check_id="C-08", message="i")
|
r.add(severity="INFO", check_id="C-08", message="i")
|
||||||
text = render_text(r)
|
text = render_text(r)
|
||||||
assert "1 fail" in text
|
assert "1 assessment-fail" in text
|
||||||
assert "1 warn" in text
|
assert "1 warn" in text
|
||||||
assert "1 info" in text
|
assert "1 info" in text
|
||||||
|
|
||||||
@@ -443,7 +453,7 @@ class TestReportToDict:
|
|||||||
r = ConsistencyReport(repo_slug="r", repo_path="/p")
|
r = ConsistencyReport(repo_slug="r", repo_path="/p")
|
||||||
d = report_to_dict(r)
|
d = report_to_dict(r)
|
||||||
assert d["result"] == "pass"
|
assert d["result"] == "pass"
|
||||||
assert d["summary"] == {"fail": 0, "warn": 0, "info": 0}
|
assert d["summary"] == {"fail": 0, "automation_error": 0, "warn": 0, "info": 0}
|
||||||
assert d["issues"] == []
|
assert d["issues"] == []
|
||||||
|
|
||||||
def test_fail_result(self):
|
def test_fail_result(self):
|
||||||
|
|||||||
@@ -17,8 +17,20 @@ async def _create_topic(client, domain_slug="legacy-domain", slug="legacy-topic"
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_workplan(client, topic_id, slug="legacy-wp", title="Legacy WP"):
|
async def _create_repo(client, domain_slug="legacy-domain", slug="legacy-repo"):
|
||||||
|
r = await client.post("/repos/", json={
|
||||||
|
"domain_slug": domain_slug,
|
||||||
|
"slug": slug,
|
||||||
|
"name": "Legacy Repo",
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_workplan(client, topic_id, domain_slug="legacy-domain", slug="legacy-wp", title="Legacy WP"):
|
||||||
|
repo = await _create_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
|
||||||
r = await client.post("/workplans/", json={
|
r = await client.post("/workplans/", json={
|
||||||
|
"repo_id": repo["id"],
|
||||||
"topic_id": topic_id,
|
"topic_id": topic_id,
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ async def _create_topic(client, domain_slug="mcp-domain"):
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_workstream(client, topic_id):
|
from tests.conftest import create_test_repo, create_test_workplan
|
||||||
r = await client.post("/workstreams/", json={
|
|
||||||
"topic_id": topic_id, "slug": "mcp-ws", "title": "MCP Workstream",
|
|
||||||
})
|
async def _create_workstream(client, topic_id, domain_slug="mcp-domain"):
|
||||||
assert r.status_code == 201
|
repo = await create_test_repo(client, domain_slug=domain_slug, slug="mcp-repo")
|
||||||
return r.json()
|
return await create_test_workplan(
|
||||||
|
client, repo_id=repo["id"], topic_id=topic_id, slug="mcp-ws", title="MCP Workstream",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -19,15 +19,16 @@ async def _call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
class TestMCPWriteTools:
|
class TestMCPWriteTools:
|
||||||
async def test_create_workstream_returns_rest_shape_and_emits_progress(self, monkeypatch):
|
async def test_create_workplan_returns_rest_shape_and_emits_progress(self, monkeypatch):
|
||||||
calls: list[tuple[str, dict[str, Any]]] = []
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
calls.append((path, body))
|
calls.append((path, body))
|
||||||
if path == "/workstreams":
|
if path == "/workplans":
|
||||||
return {
|
return {
|
||||||
"id": "ws-1",
|
"id": "wp-1",
|
||||||
"topic_id": body["topic_id"],
|
"repo_id": body["repo_id"],
|
||||||
|
"topic_id": body.get("topic_id"),
|
||||||
"title": body["title"],
|
"title": body["title"],
|
||||||
"slug": body["slug"],
|
"slug": body["slug"],
|
||||||
"status": body["status"],
|
"status": body["status"],
|
||||||
@@ -39,20 +40,42 @@ class TestMCPWriteTools:
|
|||||||
monkeypatch.setattr(server, "_post", fake_post)
|
monkeypatch.setattr(server, "_post", fake_post)
|
||||||
|
|
||||||
body = await _call_tool(
|
body = await _call_tool(
|
||||||
"create_workstream",
|
"create_workplan",
|
||||||
{"topic_id": "topic-1", "title": "MCP Reliable Write"},
|
{"repo_id": "repo-1", "topic_id": "topic-1", "title": "MCP Reliable Write"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert body == {
|
assert body == {
|
||||||
"id": "ws-1",
|
"id": "wp-1",
|
||||||
|
"repo_id": "repo-1",
|
||||||
"topic_id": "topic-1",
|
"topic_id": "topic-1",
|
||||||
"title": "MCP Reliable Write",
|
"title": "MCP Reliable Write",
|
||||||
"slug": "mcp-reliable-write",
|
"slug": "mcp-reliable-write",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
}
|
}
|
||||||
assert [path for path, _ in calls] == ["/workstreams", "/progress"]
|
assert [path for path, _ in calls] == ["/workplans", "/progress"]
|
||||||
assert calls[1][1]["workstream_id"] == "ws-1"
|
assert calls[1][1]["workplan_id"] == "wp-1"
|
||||||
assert calls[1][1]["event_type"] == "workstream_created"
|
assert calls[1][1]["event_type"] == "workplan_created"
|
||||||
|
|
||||||
|
async def test_create_workstream_legacy_alias_uses_workplans_endpoint(self, monkeypatch):
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
calls.append((path, body))
|
||||||
|
if path == "/workplans":
|
||||||
|
return {"id": "wp-1", "repo_id": body["repo_id"], "title": body["title"], "slug": body["slug"], "status": "active"}
|
||||||
|
if path == "/progress":
|
||||||
|
return {"id": "event-1", **body}
|
||||||
|
raise AssertionError(f"unexpected POST {path}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_post", fake_post)
|
||||||
|
|
||||||
|
body = await _call_tool(
|
||||||
|
"create_workstream",
|
||||||
|
{"repo_id": "repo-1", "title": "Legacy alias"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert body["id"] == "wp-1"
|
||||||
|
assert [path for path, _ in calls] == ["/workplans", "/progress"]
|
||||||
|
|
||||||
async def test_create_task_returns_rest_shape_and_emits_progress(self, monkeypatch):
|
async def test_create_task_returns_rest_shape_and_emits_progress(self, monkeypatch):
|
||||||
calls: list[tuple[str, dict[str, Any]]] = []
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
@@ -62,7 +85,8 @@ class TestMCPWriteTools:
|
|||||||
if path == "/tasks":
|
if path == "/tasks":
|
||||||
return {
|
return {
|
||||||
"id": "task-1",
|
"id": "task-1",
|
||||||
"workstream_id": body["workstream_id"],
|
"workplan_id": body.get("workplan_id") or body.get("workstream_id"),
|
||||||
|
"workstream_id": body.get("workplan_id") or body.get("workstream_id"),
|
||||||
"title": body["title"],
|
"title": body["title"],
|
||||||
"priority": body["priority"],
|
"priority": body["priority"],
|
||||||
"status": "todo",
|
"status": "todo",
|
||||||
@@ -80,6 +104,7 @@ class TestMCPWriteTools:
|
|||||||
|
|
||||||
assert body == {
|
assert body == {
|
||||||
"id": "task-1",
|
"id": "task-1",
|
||||||
|
"workplan_id": "ws-1",
|
||||||
"workstream_id": "ws-1",
|
"workstream_id": "ws-1",
|
||||||
"title": "MCP task",
|
"title": "MCP task",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
@@ -266,18 +291,18 @@ class TestMCPWriteTools:
|
|||||||
|
|
||||||
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
calls.append((path, body))
|
calls.append((path, body))
|
||||||
return {"error": "API 422: invalid topic"}
|
return {"error": "API 422: invalid repo"}
|
||||||
|
|
||||||
monkeypatch.setattr(server, "_post", fake_post)
|
monkeypatch.setattr(server, "_post", fake_post)
|
||||||
|
|
||||||
body = await _call_tool(
|
body = await _call_tool(
|
||||||
"create_workstream",
|
"create_workstream",
|
||||||
{"topic_id": "bad-topic", "title": "No progress on failure"},
|
{"repo_id": "bad-repo", "title": "No progress on failure"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert body["tool"] == "create_workstream"
|
assert body["tool"] == "create_workstream"
|
||||||
assert body["error"] == "API 422: invalid topic"
|
assert body["error"] == "API 422: invalid repo"
|
||||||
assert [path for path, _ in calls] == ["/workstreams"]
|
assert [path for path, _ in calls] == ["/workplans"]
|
||||||
|
|
||||||
async def test_record_decision_missing_id_is_clear_and_skips_progress(self, monkeypatch):
|
async def test_record_decision_missing_id_is_clear_and_skips_progress(self, monkeypatch):
|
||||||
calls: list[tuple[str, dict[str, Any]]] = []
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|||||||
@@ -20,14 +20,18 @@ async def _create_topic(client, domain_slug="digest", slug="digest-topic", title
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream"):
|
from tests.conftest import create_test_repo, create_test_workplan
|
||||||
response = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": title})
|
|
||||||
assert response.status_code == 201, response.text
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_task(client, workstream_id, title="Digest task"):
|
async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream", domain_slug="digest"):
|
||||||
response = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title})
|
repo = await create_test_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
|
||||||
|
return await create_test_workplan(
|
||||||
|
client, repo_id=repo["id"], topic_id=topic_id, slug=slug, title=title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_task(client, workplan_id, title="Digest task"):
|
||||||
|
response = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": title})
|
||||||
assert response.status_code == 201, response.text
|
assert response.status_code == 201, response.text
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@@ -277,12 +281,16 @@ class TestRecentlyOnScopeRoutes:
|
|||||||
|
|
||||||
await _create_domain(client, slug="broken", name="Broken Domain")
|
await _create_domain(client, slug="broken", name="Broken Domain")
|
||||||
broken_topic = await _create_topic(client, domain_slug="broken", slug="broken-topic")
|
broken_topic = await _create_topic(client, domain_slug="broken", slug="broken-topic")
|
||||||
broken_workstream = await _create_workstream(client, broken_topic["id"], slug="broken-ws")
|
broken_workstream = await _create_workstream(
|
||||||
|
client, broken_topic["id"], slug="broken-ws", domain_slug="broken",
|
||||||
|
)
|
||||||
await _create_task(client, broken_workstream["id"], title="Broken source")
|
await _create_task(client, broken_workstream["id"], title="Broken source")
|
||||||
|
|
||||||
await _create_domain(client, slug="good", name="Good Domain")
|
await _create_domain(client, slug="good", name="Good Domain")
|
||||||
good_topic = await _create_topic(client, domain_slug="good", slug="good-topic")
|
good_topic = await _create_topic(client, domain_slug="good", slug="good-topic")
|
||||||
good_workstream = await _create_workstream(client, good_topic["id"], slug="good-ws")
|
good_workstream = await _create_workstream(
|
||||||
|
client, good_topic["id"], slug="good-ws", domain_slug="good",
|
||||||
|
)
|
||||||
await _create_task(client, good_workstream["id"], title="Good source")
|
await _create_task(client, good_workstream["id"], title="Good source")
|
||||||
|
|
||||||
response = await client.post("/recently-on-scope/hourly", json={"range": "1d"})
|
response = await client.post("/recently-on-scope/hourly", json={"range": "1d"})
|
||||||
|
|||||||
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()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_workstream(client, topic_id, slug="test-ws", title="Test WS", status="active", **extra):
|
async def _create_workplan(client, repo_id, topic_id=None, slug="test-ws", title="Test WS", status="active", **extra):
|
||||||
payload = {
|
payload = {
|
||||||
"topic_id": topic_id, "slug": slug, "title": title, "status": status,
|
"repo_id": repo_id, "slug": slug, "title": title, "status": status,
|
||||||
}
|
}
|
||||||
|
if topic_id is not None:
|
||||||
|
payload["topic_id"] = topic_id
|
||||||
payload.update(extra)
|
payload.update(extra)
|
||||||
r = await client.post("/workstreams/", json=payload)
|
r = await client.post("/workplans/", json=payload)
|
||||||
assert r.status_code == 201, r.text
|
assert r.status_code == 201, r.text
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_task(client, workstream_id, title="Test task"):
|
async def _create_workstream(client, topic_id=None, repo_id=None, slug="test-ws", title="Test WS", status="active", **extra):
|
||||||
|
if repo_id is None:
|
||||||
|
if topic_id is None:
|
||||||
|
await _create_domain(client)
|
||||||
|
topic = await _create_topic(client)
|
||||||
|
topic_id = topic["id"]
|
||||||
|
repo = await _create_repo(client, slug=f"{slug}-repo")
|
||||||
|
repo_id = repo["id"]
|
||||||
|
return await _create_workplan(
|
||||||
|
client, repo_id=repo_id, topic_id=topic_id, slug=slug, title=title, status=status, **extra
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_task(client, workplan_id, title="Test task"):
|
||||||
r = await client.post("/tasks/", json={
|
r = await client.post("/tasks/", json={
|
||||||
"workstream_id": workstream_id, "title": title,
|
"workplan_id": workplan_id, "title": title,
|
||||||
})
|
})
|
||||||
assert r.status_code == 201, r.text
|
assert r.status_code == 201, r.text
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -16,19 +16,20 @@ async def _create_topic(client, domain_slug: str = "bulk-domain"):
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_workstream(client, topic_id: str):
|
from tests.conftest import create_test_repo, create_test_workplan
|
||||||
r = await client.post(
|
|
||||||
"/workstreams/",
|
|
||||||
json={"topic_id": topic_id, "slug": "bulk-ws", "title": "Bulk Workstream"},
|
async def _create_workstream(client, topic_id: str, domain_slug: str = "bulk-domain"):
|
||||||
|
repo = await create_test_repo(client, domain_slug=domain_slug, slug="bulk-repo")
|
||||||
|
return await create_test_workplan(
|
||||||
|
client, repo_id=repo["id"], topic_id=topic_id, slug="bulk-ws", title="Bulk Workstream",
|
||||||
)
|
)
|
||||||
assert r.status_code == 201
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_task(client, workstream_id: str, title: str):
|
async def _create_task(client, workplan_id: str, title: str):
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
"/tasks/",
|
"/tasks/",
|
||||||
json={"workstream_id": workstream_id, "title": title},
|
json={"workplan_id": workplan_id, "title": title},
|
||||||
)
|
)
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="testdomain"):
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_workstream(client, topic_id, slug="ws1"):
|
from tests.conftest import create_test_repo, create_test_workplan
|
||||||
r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": "WS"})
|
|
||||||
assert r.status_code == 201, r.text
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_task(client, workstream_id):
|
async def _create_workstream(client, topic_id, slug="ws1", domain_slug="testdomain"):
|
||||||
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": "task"})
|
repo = await create_test_repo(client, domain_slug=domain_slug, slug=f"{slug}-repo")
|
||||||
|
return await create_test_workplan(client, repo_id=repo["id"], topic_id=topic_id, slug=slug, title="WS")
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_task(client, workplan_id):
|
||||||
|
r = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": "task"})
|
||||||
assert r.status_code == 201, r.text
|
assert r.status_code == 201, r.text
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|||||||
@@ -25,14 +25,16 @@ async def _create_topic(client, domain_slug="td"):
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_workstream(client, topic_id):
|
from tests.conftest import create_test_repo, create_test_workplan
|
||||||
r = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": "ws", "title": "WS"})
|
|
||||||
assert r.status_code == 201, r.text
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_task(client, workstream_id, title="my task"):
|
async def _create_workstream(client, topic_id, domain_slug="td"):
|
||||||
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title})
|
repo = await create_test_repo(client, domain_slug=domain_slug, slug="td-repo")
|
||||||
|
return await create_test_workplan(client, repo_id=repo["id"], topic_id=topic_id, slug="ws", title="WS")
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_task(client, workplan_id, title="my task"):
|
||||||
|
r = await client.post("/tasks/", json={"workplan_id": workplan_id, "title": title})
|
||||||
assert r.status_code == 201, r.text
|
assert r.status_code == 201, r.text
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ type: workplan
|
|||||||
title: "Repo-anchored classification spine (CUST-WP-0050 implementation)"
|
title: "Repo-anchored classification spine (CUST-WP-0050 implementation)"
|
||||||
domain: custodian
|
domain: custodian
|
||||||
repo: state-hub
|
repo: state-hub
|
||||||
status: ready
|
status: finished
|
||||||
owner: custodian
|
owner: custodian
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-06-22"
|
created: "2026-06-22"
|
||||||
updated: "2026-06-22"
|
updated: "2026-06-22"
|
||||||
|
started: "2026-06-22"
|
||||||
|
finished: "2026-06-22"
|
||||||
state_hub_workstream_id: "8dc7d106-11e2-41df-b512-89ed69d2a65f"
|
state_hub_workstream_id: "8dc7d106-11e2-41df-b512-89ed69d2a65f"
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ repo-owned files (ADR-001).
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0065-T01
|
id: STATE-WP-0065-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "14cf65f1-e5af-4905-8de4-bc8986ef078e"
|
state_hub_task_id: "14cf65f1-e5af-4905-8de4-bc8986ef078e"
|
||||||
```
|
```
|
||||||
@@ -82,7 +84,7 @@ dry-run report is reviewed, and a tested downgrade path exists.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0065-T02
|
id: STATE-WP-0065-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d3afcae1-d47e-42f1-bad8-1de4bd1f126a"
|
state_hub_task_id: "d3afcae1-d47e-42f1-bad8-1de4bd1f126a"
|
||||||
```
|
```
|
||||||
@@ -103,7 +105,7 @@ are green.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0065-T03
|
id: STATE-WP-0065-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "bab90f0c-238e-4f43-b34c-a8cdd8faf0e6"
|
state_hub_task_id: "bab90f0c-238e-4f43-b34c-a8cdd8faf0e6"
|
||||||
```
|
```
|
||||||
@@ -122,7 +124,7 @@ emits a report of registered / updated / skipped / invalid.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0065-T04
|
id: STATE-WP-0065-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "67c54009-823d-466b-beaf-f27351c279f4"
|
state_hub_task_id: "67c54009-823d-466b-beaf-f27351c279f4"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user