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:
@@ -9,23 +9,23 @@ from api.models.agent_message import AgentMessage
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.models.task import TaskStatus
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
|
||||
from api.services.lifecycle import (
|
||||
should_activate_parent_for_task_start,
|
||||
status_value,
|
||||
transition_task_status,
|
||||
transition_workstream_status,
|
||||
transition_workplan_status,
|
||||
)
|
||||
from api.task_status import TERMINAL_TASK_STATUSES
|
||||
from api.services.reconciliation import (
|
||||
ReconciliationClass,
|
||||
StateChangeClassification,
|
||||
classify_task_status_change,
|
||||
classify_workstream_status_change,
|
||||
classify_workplan_status_change,
|
||||
)
|
||||
from api.services.workplan_files import (
|
||||
find_workplan_for_workstream,
|
||||
find_workplan_for_workplan,
|
||||
patch_task_status,
|
||||
patch_workplan_status,
|
||||
resolve_repo_path,
|
||||
@@ -33,7 +33,7 @@ from api.services.workplan_files import (
|
||||
task_block_linked,
|
||||
workplan_status,
|
||||
)
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
|
||||
|
||||
@@ -51,7 +51,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification:
|
||||
|
||||
|
||||
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
||||
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
|
||||
result = await session.execute(select(Task.status).where(Task.workplan_id == workstream_id))
|
||||
statuses = [status_value(row[0]) for row in result.all()]
|
||||
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
|
||||
|
||||
@@ -98,13 +98,13 @@ async def classify_state_change(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> StateChangeResponse:
|
||||
if body.target_type == "workstream":
|
||||
ws = await session.get(Workstream, body.target_id)
|
||||
ws = await session.get(Workplan, body.target_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
|
||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
|
||||
repo_path = resolve_repo_path(repo)
|
||||
workplan_ref = find_workplan_for_workstream(repo, ws.id) if repo_path else None
|
||||
workplan_ref = find_workplan_for_workplan(repo, ws.id) if repo_path else None
|
||||
actual_file_backed = workplan_ref is not None
|
||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||
file_backed = (
|
||||
@@ -122,9 +122,9 @@ async def classify_state_change(
|
||||
if body.tasks_terminal is not None
|
||||
else await _workstream_tasks_terminal(session, ws.id)
|
||||
)
|
||||
current_status = normalize_workstream_status(ws.status)
|
||||
target_status = normalize_workstream_status(body.target_status)
|
||||
classification = classify_workstream_status_change(
|
||||
current_status = normalize_workplan_status(ws.status)
|
||||
target_status = normalize_workplan_status(body.target_status)
|
||||
classification = classify_workplan_status_change(
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
file_backed=file_backed,
|
||||
@@ -136,7 +136,7 @@ async def classify_state_change(
|
||||
conflict = False
|
||||
if body.apply:
|
||||
expected_status = (
|
||||
normalize_workstream_status(body.expected_current_status)
|
||||
normalize_workplan_status(body.expected_current_status)
|
||||
if body.expected_current_status is not None
|
||||
else None
|
||||
)
|
||||
@@ -153,7 +153,7 @@ async def classify_state_change(
|
||||
)
|
||||
conflict = True
|
||||
elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref:
|
||||
file_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
||||
file_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||
if file_status and file_status != current_status:
|
||||
classification = _conflict(
|
||||
f"workplan file status {file_status!r} differs from cached DB status {current_status!r}",
|
||||
@@ -163,7 +163,7 @@ async def classify_state_change(
|
||||
else:
|
||||
try:
|
||||
patch_workplan_status(workplan_ref.path, target_status)
|
||||
patched_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
||||
patched_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||
except OSError as exc:
|
||||
classification = _conflict(
|
||||
f"workplan file write failed: {exc}",
|
||||
@@ -178,7 +178,7 @@ async def classify_state_change(
|
||||
)
|
||||
conflict = True
|
||||
else:
|
||||
transition_workstream_status(ws, target_status)
|
||||
transition_workplan_status(ws, target_status)
|
||||
await session.commit()
|
||||
write_result = "applied"
|
||||
|
||||
@@ -221,10 +221,10 @@ async def classify_state_change(
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
ws = await session.get(Workplan, task.workplan_id)
|
||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
|
||||
repo_path = resolve_repo_path(repo)
|
||||
workplan_ref = find_workplan_for_workstream(repo, ws.id) if ws and repo_path else None
|
||||
workplan_ref = find_workplan_for_workplan(repo, ws.id) if ws and repo_path else None
|
||||
actual_file_backed = workplan_ref is not None
|
||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||
file_backed = (
|
||||
@@ -291,7 +291,7 @@ async def classify_state_change(
|
||||
parent_will_activate = should_activate_parent_for_task_start(
|
||||
previous_task_status=current_status,
|
||||
new_task_status=target_status,
|
||||
parent_workstream_status=ws.status if ws else None,
|
||||
parent_workplan_status=ws.status if ws else None,
|
||||
)
|
||||
try:
|
||||
original_text = workplan_ref.path.read_text(encoding="utf-8")
|
||||
@@ -299,7 +299,7 @@ async def classify_state_change(
|
||||
patched_status = status_value(task_block_status(workplan_ref.path, task.id))
|
||||
if parent_will_activate:
|
||||
patch_workplan_status(workplan_ref.path, "active")
|
||||
parent_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
||||
parent_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||
if parent_status != "active":
|
||||
if original_text is not None:
|
||||
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user