Add reconciliation file write-through

This commit is contained in:
2026-05-23 17:41:30 +02:00
parent b78d73611c
commit 757c2c3345
5 changed files with 378 additions and 19 deletions

View File

@@ -5,14 +5,23 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
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.schemas.reconciliation import StateChangeRequest, StateChangeResponse
from api.services.lifecycle import status_value
from api.services.reconciliation import (
ReconciliationClass,
classify_task_status_change,
classify_workstream_status_change,
)
from api.services.workplan_files import (
find_workplan_for_workstream,
patch_task_status,
patch_workplan_status,
task_block_linked,
)
from api.workplan_status import normalize_workstream_status
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
@@ -38,33 +47,58 @@ async def classify_state_change(
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
file_backed = _bool_or_default(body.file_backed, ws.repo_id is not None)
archived_file = _bool_or_default(body.archived_file, False)
repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
workplan_ref = find_workplan_for_workstream(repo, ws.id)
actual_file_backed = workplan_ref is not None
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
file_backed = (
actual_file_backed
if body.apply
else _bool_or_default(body.file_backed, actual_file_backed)
)
archived_file = (
actual_archived_file
if body.apply
else _bool_or_default(body.archived_file, actual_archived_file)
)
tasks_terminal = (
body.tasks_terminal
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=ws.status,
target_status=body.target_status,
current_status=current_status,
target_status=target_status,
file_backed=file_backed,
archived_file=archived_file,
tasks_terminal=tasks_terminal,
)
write_result = "not_attempted"
if body.apply:
if classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref:
patch_workplan_status(workplan_ref.path, target_status)
ws.status = target_status
await session.commit()
write_result = "applied"
else:
write_result = "not_applicable"
return StateChangeResponse(
target_type=body.target_type,
target_id=body.target_id,
actor=body.actor,
intent=body.intent,
current_status=normalize_workstream_status(ws.status),
target_status=normalize_workstream_status(body.target_status),
current_status=current_status,
target_status=target_status,
file_backed=file_backed,
archived_file=archived_file,
tasks_terminal=tasks_terminal,
reconciliation_class=classification.reconciliation_class,
reason=classification.reason,
follow_up=classification.follow_up,
write_through_result=write_result,
workplan_path=workplan_ref.relative_path if workplan_ref else None,
)
task = await session.get(Task, body.target_id)
@@ -72,28 +106,64 @@ async def classify_state_change(
raise HTTPException(status_code=404, detail="Task not found")
ws = await session.get(Workstream, task.workstream_id)
file_backed = _bool_or_default(body.file_backed, bool(ws and ws.repo_id))
archived_file = _bool_or_default(body.archived_file, False)
task_linked = _bool_or_default(body.task_linked, True)
repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
workplan_ref = find_workplan_for_workstream(repo, ws.id) if ws else None
actual_file_backed = workplan_ref is not None
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
file_backed = (
actual_file_backed
if body.apply
else _bool_or_default(body.file_backed, actual_file_backed)
)
archived_file = (
actual_archived_file
if body.apply
else _bool_or_default(body.archived_file, actual_archived_file)
)
actual_task_linked = bool(workplan_ref and task_block_linked(workplan_ref.path, task.id))
task_linked = (
actual_task_linked
if body.apply
else _bool_or_default(body.task_linked, actual_task_linked)
)
current_status = status_value(task.status)
target_status = status_value(body.target_status)
classification = classify_task_status_change(
current_status=task.status,
target_status=body.target_status,
current_status=current_status,
target_status=target_status,
file_backed=file_backed,
archived_file=archived_file,
task_linked=task_linked,
blocking_reason=body.blocking_reason,
)
write_result = "not_attempted"
if body.apply:
if (
classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH
and workplan_ref
and actual_task_linked
):
patch_task_status(workplan_ref.path, task.id, target_status)
task.status = TaskStatus(target_status)
if body.blocking_reason is not None:
task.blocking_reason = body.blocking_reason
await session.commit()
write_result = "applied"
else:
write_result = "not_applicable"
return StateChangeResponse(
target_type=body.target_type,
target_id=body.target_id,
actor=body.actor,
intent=body.intent,
current_status=status_value(task.status),
target_status=status_value(body.target_status),
current_status=current_status,
target_status=target_status,
file_backed=file_backed,
archived_file=archived_file,
task_linked=task_linked,
reconciliation_class=classification.reconciliation_class,
reason=classification.reason,
follow_up=classification.follow_up,
write_through_result=write_result,
workplan_path=workplan_ref.relative_path if workplan_ref else None,
)

View File

@@ -20,6 +20,7 @@ class StateChangeRequest(BaseModel):
task_linked: bool | None = None
tasks_terminal: bool | None = None
blocking_reason: str | None = None
apply: bool = False
class StateChangeResponse(BaseModel):
@@ -36,4 +37,5 @@ class StateChangeResponse(BaseModel):
reconciliation_class: ReconciliationClass
reason: str
follow_up: str
write_through_result: Literal["not_attempted"] = "not_attempted"
write_through_result: Literal["not_attempted", "applied", "not_applicable"] = "not_attempted"
workplan_path: str | None = None

View File

@@ -0,0 +1,155 @@
from __future__ import annotations
import re
import socket
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from api.models.managed_repo import ManagedRepo
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
@dataclass(frozen=True)
class WorkplanFileRef:
repo_path: Path
path: Path
archived: bool
@property
def relative_path(self) -> str:
return str(self.path.relative_to(self.repo_path))
def resolve_repo_path(repo: ManagedRepo | None) -> Path | None:
if repo is None:
return None
hostname = socket.gethostname()
host_paths = repo.host_paths or {}
candidates = [host_paths.get(hostname), repo.local_path]
for raw in candidates:
if not raw:
continue
path = Path(raw).expanduser()
if path.is_dir():
return path
return None
def find_workplan_for_workstream(
repo: ManagedRepo | None,
workstream_id: uuid.UUID,
) -> WorkplanFileRef | None:
repo_path = resolve_repo_path(repo)
if repo_path is None:
return None
workplans_dir = repo_path / "workplans"
for directory, archived in (
(workplans_dir, False),
(workplans_dir / "archived", True),
):
if not directory.is_dir():
continue
for path in sorted(directory.glob("*.md")):
meta = _frontmatter(path)
if str(meta.get("state_hub_workstream_id", "")).strip().strip('"') == str(workstream_id):
return WorkplanFileRef(repo_path=repo_path, path=path, archived=archived)
return None
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
return _task_block_for_task(path, task_id) is not None
def patch_workplan_status(path: Path, status: str) -> bool:
return _patch_frontmatter_field(path, "status", status)
def patch_task_status(path: Path, task_id: uuid.UUID, status: str) -> bool:
text = path.read_text(encoding="utf-8")
def _replace(match: re.Match) -> str:
block = match.group(0)
meta = _parse_task_block(match.group(1))
if str(meta.get("state_hub_task_id", "")).strip().strip('"') != str(task_id):
return block
replaced = re.sub(
r"^(status:\s*)\S+",
rf"\g<1>{status}",
block,
count=1,
flags=re.MULTILINE,
)
if replaced != block:
return replaced
return block.replace("\n```", f"\nstatus: {status}\n```", 1)
new_text = _TASK_BLOCK_RE.sub(_replace, text)
if new_text == text:
return False
path.write_text(new_text, encoding="utf-8")
return True
def _frontmatter(path: Path) -> dict[str, Any]:
try:
text = path.read_text(encoding="utf-8")
except OSError:
return {}
if not text.startswith("---"):
return {}
parts = text.split("---", 2)
if len(parts) < 3:
return {}
try:
return yaml.safe_load(parts[1].strip()) or {}
except yaml.YAMLError:
return {}
def _patch_frontmatter_field(path: Path, key: str, value: str) -> bool:
text = path.read_text(encoding="utf-8")
if not text.startswith("---"):
return False
lines = text.split("\n")
close_idx = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
close_idx = i
break
if close_idx is None:
return False
new_line = f"{key}: {value}"
for i in range(1, close_idx):
if re.match(rf"^\s*{re.escape(key)}\s*:", lines[i]):
if lines[i] == new_line:
return False
lines[i] = new_line
path.write_text("\n".join(lines), encoding="utf-8")
return True
lines.insert(close_idx, new_line)
path.write_text("\n".join(lines), encoding="utf-8")
return True
def _task_block_for_task(path: Path, task_id: uuid.UUID) -> dict[str, Any] | None:
text = path.read_text(encoding="utf-8")
for match in _TASK_BLOCK_RE.finditer(text):
meta = _parse_task_block(match.group(1))
if str(meta.get("state_hub_task_id", "")).strip().strip('"') == str(task_id):
return meta
return None
def _parse_task_block(raw: str) -> dict[str, Any]:
try:
return yaml.safe_load(raw.strip()) or {}
except yaml.YAMLError:
return {}