generated from coulomb/repo-seed
Classify UI state reconciliation changes
This commit is contained in:
148
api/services/reconciliation.py
Normal file
148
api/services/reconciliation.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from api.services.lifecycle import status_value
|
||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
||||
|
||||
|
||||
class ReconciliationClass(str, Enum):
|
||||
WRITE_THROUGH = "write_through"
|
||||
DEFERRED = "deferred"
|
||||
HUMAN_CONFIRMATION = "human_confirmation"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateChangeClassification:
|
||||
reconciliation_class: ReconciliationClass
|
||||
reason: str
|
||||
follow_up: str
|
||||
|
||||
|
||||
WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"}
|
||||
TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
||||
|
||||
|
||||
def classify_workstream_status_change(
|
||||
*,
|
||||
current_status: Any,
|
||||
target_status: Any,
|
||||
file_backed: bool,
|
||||
archived_file: bool = False,
|
||||
tasks_terminal: bool | None = None,
|
||||
) -> StateChangeClassification:
|
||||
"""Classify a UI-originated workstream status transition."""
|
||||
current = normalize_workstream_status(current_status)
|
||||
target = normalize_workstream_status(target_status)
|
||||
|
||||
if not file_backed:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.DEFERRED,
|
||||
"workstream is not backed by a local workplan file",
|
||||
"create a reconciliation record for operator follow-up",
|
||||
)
|
||||
if archived_file:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.HUMAN_CONFIRMATION,
|
||||
"workplan file is archived",
|
||||
"confirm whether to restore the file or leave the DB state unchanged",
|
||||
)
|
||||
if current == target:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.WRITE_THROUGH,
|
||||
"status is unchanged",
|
||||
"no file update required",
|
||||
)
|
||||
if target in WRITE_THROUGH_WORKSTREAM_STATUSES and current not in CLOSED_WORKSTREAM_STATUSES:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.WRITE_THROUGH,
|
||||
"open lifecycle transition can be represented in workplan frontmatter",
|
||||
"patch workplan status and sync the DB from file",
|
||||
)
|
||||
if target == "finished":
|
||||
if tasks_terminal is True:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.WRITE_THROUGH,
|
||||
"all known tasks are terminal",
|
||||
"patch workplan status to finished and sync the DB from file",
|
||||
)
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.HUMAN_CONFIRMATION,
|
||||
"finishing with non-terminal or unknown task state can hide open work",
|
||||
"ask for confirmation or finish/cancel remaining tasks first",
|
||||
)
|
||||
if target == "archived":
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.HUMAN_CONFIRMATION,
|
||||
"archiving may move files and hide active context",
|
||||
"ask for confirmation and run the archive workflow",
|
||||
)
|
||||
if target == "blocked":
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.HUMAN_CONFIRMATION,
|
||||
"blocked workstreams need an explicit blocker or dependency reason",
|
||||
"capture the blocker before writing status",
|
||||
)
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.DEFERRED,
|
||||
f"unsupported workstream status transition {current!r} -> {target!r}",
|
||||
"create a reconciliation record for operator review",
|
||||
)
|
||||
|
||||
|
||||
def classify_task_status_change(
|
||||
*,
|
||||
current_status: Any,
|
||||
target_status: Any,
|
||||
file_backed: bool,
|
||||
task_linked: bool,
|
||||
archived_file: bool = False,
|
||||
blocking_reason: str | None = None,
|
||||
) -> StateChangeClassification:
|
||||
"""Classify a UI-originated task status transition."""
|
||||
current = status_value(current_status)
|
||||
target = status_value(target_status)
|
||||
|
||||
if not file_backed:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.DEFERRED,
|
||||
"task belongs to a workstream without a local workplan file",
|
||||
"create a reconciliation record for operator follow-up",
|
||||
)
|
||||
if archived_file:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.HUMAN_CONFIRMATION,
|
||||
"task belongs to an archived workplan file",
|
||||
"confirm whether to restore or edit the archived file",
|
||||
)
|
||||
if not task_linked:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.DEFERRED,
|
||||
"task is not linked to a task block in the workplan file",
|
||||
"create or repair the task block link before writing status",
|
||||
)
|
||||
if current == target:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.WRITE_THROUGH,
|
||||
"status is unchanged",
|
||||
"no file update required",
|
||||
)
|
||||
if target == "blocked" and not (blocking_reason or "").strip():
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.HUMAN_CONFIRMATION,
|
||||
"blocked tasks require a blocking reason",
|
||||
"capture the blocker before writing status",
|
||||
)
|
||||
if target in TASK_STATUSES:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.WRITE_THROUGH,
|
||||
"task status can be represented in the workplan task block",
|
||||
"patch task block status and sync the DB from file",
|
||||
)
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.DEFERRED,
|
||||
f"unsupported task status transition {current!r} -> {target!r}",
|
||||
"create a reconciliation record for operator review",
|
||||
)
|
||||
125
tests/test_reconciliation.py
Normal file
125
tests/test_reconciliation.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from api.services.reconciliation import (
|
||||
ReconciliationClass,
|
||||
classify_task_status_change,
|
||||
classify_workstream_status_change,
|
||||
)
|
||||
|
||||
|
||||
def test_workstream_open_transition_writes_through():
|
||||
result = classify_workstream_status_change(
|
||||
current_status="active",
|
||||
target_status="backlog",
|
||||
file_backed=True,
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.WRITE_THROUGH
|
||||
assert "frontmatter" in result.reason
|
||||
|
||||
|
||||
def test_workstream_unfiled_transition_is_deferred():
|
||||
result = classify_workstream_status_change(
|
||||
current_status="active",
|
||||
target_status="backlog",
|
||||
file_backed=False,
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.DEFERRED
|
||||
assert "not backed" in result.reason
|
||||
|
||||
|
||||
def test_workstream_finish_requires_terminal_tasks():
|
||||
result = classify_workstream_status_change(
|
||||
current_status="active",
|
||||
target_status="finished",
|
||||
file_backed=True,
|
||||
tasks_terminal=False,
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION
|
||||
assert "open work" in result.reason
|
||||
|
||||
|
||||
def test_workstream_finish_with_terminal_tasks_writes_through():
|
||||
result = classify_workstream_status_change(
|
||||
current_status="active",
|
||||
target_status="finished",
|
||||
file_backed=True,
|
||||
tasks_terminal=True,
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.WRITE_THROUGH
|
||||
assert "finished" in result.follow_up
|
||||
|
||||
|
||||
def test_archived_workstream_file_requires_confirmation():
|
||||
result = classify_workstream_status_change(
|
||||
current_status="finished",
|
||||
target_status="active",
|
||||
file_backed=True,
|
||||
archived_file=True,
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION
|
||||
assert "archived" in result.reason
|
||||
|
||||
|
||||
def test_task_status_update_writes_through_to_task_block():
|
||||
result = classify_task_status_change(
|
||||
current_status="todo",
|
||||
target_status="in_progress",
|
||||
file_backed=True,
|
||||
task_linked=True,
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.WRITE_THROUGH
|
||||
assert "task block" in result.reason
|
||||
|
||||
|
||||
def test_task_without_file_is_deferred():
|
||||
result = classify_task_status_change(
|
||||
current_status="todo",
|
||||
target_status="in_progress",
|
||||
file_backed=False,
|
||||
task_linked=True,
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.DEFERRED
|
||||
assert "without a local workplan file" in result.reason
|
||||
|
||||
|
||||
def test_unlinked_task_is_deferred_until_link_repaired():
|
||||
result = classify_task_status_change(
|
||||
current_status="todo",
|
||||
target_status="in_progress",
|
||||
file_backed=True,
|
||||
task_linked=False,
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.DEFERRED
|
||||
assert "not linked" in result.reason
|
||||
|
||||
|
||||
def test_blocked_task_requires_blocking_reason():
|
||||
result = classify_task_status_change(
|
||||
current_status="todo",
|
||||
target_status="blocked",
|
||||
file_backed=True,
|
||||
task_linked=True,
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION
|
||||
assert "blocking reason" in result.reason
|
||||
|
||||
|
||||
def test_blocked_task_with_reason_writes_through():
|
||||
result = classify_task_status_change(
|
||||
current_status="todo",
|
||||
target_status="blocked",
|
||||
file_backed=True,
|
||||
task_linked=True,
|
||||
blocking_reason="Waiting on dependency",
|
||||
)
|
||||
|
||||
assert result.reconciliation_class == ReconciliationClass.WRITE_THROUGH
|
||||
@@ -4,7 +4,7 @@ type: workplan
|
||||
title: "UI State Change Reconciliation"
|
||||
domain: custodian
|
||||
repo: state-hub
|
||||
status: proposed
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
planning_priority: high
|
||||
@@ -33,11 +33,26 @@ for other workstream and task state transitions.
|
||||
This workplan defines the reconciliation path between UI actions, DB state,
|
||||
repo files, and scheduled cleanup.
|
||||
|
||||
## Reconciliation Classes
|
||||
|
||||
| Class | Meaning | Default follow-up |
|
||||
|-------|---------|-------------------|
|
||||
| `write_through` | The change can be represented directly in the workplan file. | Patch the file first, then sync the DB from file state. |
|
||||
| `deferred` | The change cannot safely touch the file immediately. | Create a visible reconciliation record or task. |
|
||||
| `human_confirmation` | The change may hide work, require a blocker reason, or move archived context. | Ask for confirmation or gather missing intent before writing. |
|
||||
|
||||
## Initial Transition Classification
|
||||
|
||||
| Target | Safe write-through | Deferred | Human confirmation |
|
||||
|--------|--------------------|----------|--------------------|
|
||||
| Workstream status | Open, file-backed transitions among `proposed`, `ready`, `active`, and `backlog`; `finished` only when known tasks are terminal. | Non-file-backed workstreams, unsupported statuses, unavailable file links. | Archived files, `blocked` without explicit blocker/dependency context, `finished` with open/unknown tasks, `archived`. |
|
||||
| Task status | Linked task blocks in active workplan files for `todo`, `in_progress`, `done`, `cancelled`, and `blocked` with a blocking reason. | Non-file-backed workstreams or DB tasks without a linked task block. | Archived files and `blocked` task changes without a blocking reason. |
|
||||
|
||||
## T01 - Classify UI State Changes
|
||||
|
||||
```task
|
||||
id: STATE-WP-0048-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "0332be96-ebc7-4a7b-97c6-bbe6ae3a66ac"
|
||||
```
|
||||
@@ -49,6 +64,10 @@ confirmation.
|
||||
Done when each workstream/task transition has a reconciliation class and a
|
||||
reason.
|
||||
|
||||
Result 2026-05-23: added the initial classification matrix and
|
||||
`api.services.reconciliation` helpers for workstream/task status changes. Tests
|
||||
pin the write-through, deferred, and human-confirmation decisions.
|
||||
|
||||
## T02 - Add Reconciliation API Contract
|
||||
|
||||
```task
|
||||
|
||||
Reference in New Issue
Block a user