generated from coulomb/repo-seed
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
179 lines
5.1 KiB
Python
179 lines
5.1 KiB
Python
import uuid
|
|
from datetime import date, datetime
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import HTTPException
|
|
from pydantic import BaseModel, Field, model_validator
|
|
|
|
from api.classification import validate_classification
|
|
from hub_core.schemas.managed_repo import (
|
|
RepoCreate as CoreRepoCreate,
|
|
RepoPathRegister,
|
|
RepoRead as CoreRepoRead,
|
|
)
|
|
|
|
|
|
class ClassificationFields(BaseModel):
|
|
category: str | None = None
|
|
secondary_domains: list[str] | None = None
|
|
capability_tags: list[str] | None = None
|
|
business_stake: list[str] | None = None
|
|
business_mechanics: list[str] | None = None
|
|
classified_at: date | None = None
|
|
classified_by: str | None = None
|
|
standard_version: str | None = None
|
|
|
|
|
|
def classification_fields_set(data: dict[str, Any]) -> bool:
|
|
keys = (
|
|
"category",
|
|
"secondary_domains",
|
|
"capability_tags",
|
|
"business_stake",
|
|
"business_mechanics",
|
|
"classified_at",
|
|
"classified_by",
|
|
"standard_version",
|
|
)
|
|
return any(data.get(key) is not None for key in keys)
|
|
|
|
|
|
def validate_repo_classification_fields(
|
|
*,
|
|
domain_slug: str,
|
|
fields: dict[str, Any],
|
|
require_complete: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Validate classification fields and return normalized values for persistence."""
|
|
if not classification_fields_set(fields) and not require_complete:
|
|
return fields
|
|
|
|
block = {
|
|
"category": fields.get("category"),
|
|
"domain": domain_slug,
|
|
"secondary_domains": fields.get("secondary_domains") or [],
|
|
"capability_tags": fields.get("capability_tags") or [],
|
|
"business_stake": fields.get("business_stake") or [],
|
|
"business_mechanics": fields.get("business_mechanics") or [],
|
|
}
|
|
if require_complete or fields.get("category") is not None:
|
|
if block["category"] is None:
|
|
raise HTTPException(status_code=422, detail="`category` is required when classification is provided")
|
|
if classification_fields_set(fields) and block["category"] is not None:
|
|
errors, warnings = validate_classification(block)
|
|
if errors:
|
|
raise HTTPException(status_code=422, detail={"classification_errors": errors, "warnings": warnings})
|
|
return fields
|
|
|
|
|
|
class RepoCreate(CoreRepoCreate, ClassificationFields):
|
|
topic_id: uuid.UUID | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_classification_on_create(self) -> "RepoCreate":
|
|
validate_repo_classification_fields(
|
|
domain_slug=self.domain_slug,
|
|
fields=self.model_dump(),
|
|
require_complete=classification_fields_set(self.model_dump()),
|
|
)
|
|
return self
|
|
|
|
|
|
class RepoUpdate(ClassificationFields):
|
|
name: str | None = None
|
|
local_path: str | None = None
|
|
remote_url: str | None = None
|
|
git_fingerprint: str | None = None
|
|
description: str | None = None
|
|
topic_id: uuid.UUID | None = None
|
|
last_state_synced_at: datetime | None = None
|
|
|
|
|
|
class RepoOnboardRequest(BaseModel):
|
|
"""Start scripted onboarding for a working copy that is visible to State Hub."""
|
|
domain_slug: str
|
|
project_path: str
|
|
agent_profile: Literal["claude-code", "codex"] = "codex"
|
|
additional: bool = False
|
|
|
|
|
|
class RepoOnboardResult(BaseModel):
|
|
ok: bool
|
|
repo_slug: str | None = None
|
|
agent_profile: str
|
|
command: list[str]
|
|
stdout: str = ""
|
|
stderr: str = ""
|
|
|
|
|
|
class RepoRead(CoreRepoRead, ClassificationFields):
|
|
topic_id: uuid.UUID | None = None
|
|
sbom_source: str | None = None
|
|
last_sbom_at: datetime | None = None
|
|
last_state_synced_at: datetime | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class DispatchTask(BaseModel):
|
|
id: uuid.UUID
|
|
title: str
|
|
priority: str
|
|
status: str
|
|
needs_human: bool
|
|
|
|
|
|
class DispatchWorkplan(BaseModel):
|
|
id: uuid.UUID
|
|
title: str
|
|
status: str
|
|
pending_tasks: list[DispatchTask]
|
|
|
|
|
|
# Legacy alias
|
|
DispatchWorkstream = DispatchWorkplan
|
|
|
|
|
|
class PendingInterfaceChange(BaseModel):
|
|
id: uuid.UUID
|
|
title: str
|
|
change_type: str
|
|
interface_type: str
|
|
origin_repo_slug: str
|
|
affected_paths: list[str]
|
|
planned_for: date | None
|
|
published_at: datetime | None
|
|
|
|
|
|
class ScopeIssueDetail(BaseModel):
|
|
id: str
|
|
label: str
|
|
status: str
|
|
detail: str
|
|
missing_sections: list[str] = Field(default_factory=list)
|
|
invalid_capability_blocks: list[dict[str, Any]] = Field(default_factory=list)
|
|
needs_refresh_sections: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class RepoDispatch(BaseModel):
|
|
repo_slug: str
|
|
active_goal: dict[str, Any] | None
|
|
active_workplans: list[DispatchWorkplan]
|
|
human_interventions: list[DispatchTask]
|
|
pending_interface_changes: list[PendingInterfaceChange]
|
|
scope_needs_review: bool
|
|
scope_issue_details: list[ScopeIssueDetail]
|
|
last_state_synced_at: datetime | None
|
|
|
|
@property
|
|
def active_workstreams(self) -> list[DispatchWorkplan]:
|
|
return self.active_workplans
|
|
|
|
|
|
class RepoScopeHealth(BaseModel):
|
|
repo_slug: str
|
|
domain_slug: str | None = None
|
|
local_path: str | None = None
|
|
path_available: bool
|
|
scope_needs_review: bool
|
|
scope_issue_details: list[ScopeIssueDetail] |