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 sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
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.workstream import Workstream from api.models.workstream import Workstream
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
from api.services.lifecycle import status_value from api.services.lifecycle import status_value
from api.services.reconciliation import ( from api.services.reconciliation import (
ReconciliationClass,
classify_task_status_change, classify_task_status_change,
classify_workstream_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 from api.workplan_status import normalize_workstream_status
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"]) router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
@@ -38,33 +47,58 @@ async def classify_state_change(
if ws is None: if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found") raise HTTPException(status_code=404, detail="Workstream not found")
file_backed = _bool_or_default(body.file_backed, ws.repo_id is not None) repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
archived_file = _bool_or_default(body.archived_file, False) 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 = ( tasks_terminal = (
body.tasks_terminal body.tasks_terminal
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)
target_status = normalize_workstream_status(body.target_status)
classification = classify_workstream_status_change( classification = classify_workstream_status_change(
current_status=ws.status, current_status=current_status,
target_status=body.target_status, target_status=target_status,
file_backed=file_backed, file_backed=file_backed,
archived_file=archived_file, archived_file=archived_file,
tasks_terminal=tasks_terminal, 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( return StateChangeResponse(
target_type=body.target_type, target_type=body.target_type,
target_id=body.target_id, target_id=body.target_id,
actor=body.actor, actor=body.actor,
intent=body.intent, intent=body.intent,
current_status=normalize_workstream_status(ws.status), current_status=current_status,
target_status=normalize_workstream_status(body.target_status), target_status=target_status,
file_backed=file_backed, file_backed=file_backed,
archived_file=archived_file, archived_file=archived_file,
tasks_terminal=tasks_terminal, tasks_terminal=tasks_terminal,
reconciliation_class=classification.reconciliation_class, reconciliation_class=classification.reconciliation_class,
reason=classification.reason, reason=classification.reason,
follow_up=classification.follow_up, 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) 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") raise HTTPException(status_code=404, detail="Task not found")
ws = await session.get(Workstream, task.workstream_id) ws = await session.get(Workstream, task.workstream_id)
file_backed = _bool_or_default(body.file_backed, bool(ws and ws.repo_id)) repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
archived_file = _bool_or_default(body.archived_file, False) workplan_ref = find_workplan_for_workstream(repo, ws.id) if ws else None
task_linked = _bool_or_default(body.task_linked, True) 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( classification = classify_task_status_change(
current_status=task.status, current_status=current_status,
target_status=body.target_status, target_status=target_status,
file_backed=file_backed, file_backed=file_backed,
archived_file=archived_file, archived_file=archived_file,
task_linked=task_linked, task_linked=task_linked,
blocking_reason=body.blocking_reason, 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( return StateChangeResponse(
target_type=body.target_type, target_type=body.target_type,
target_id=body.target_id, target_id=body.target_id,
actor=body.actor, actor=body.actor,
intent=body.intent, intent=body.intent,
current_status=status_value(task.status), current_status=current_status,
target_status=status_value(body.target_status), target_status=target_status,
file_backed=file_backed, file_backed=file_backed,
archived_file=archived_file, archived_file=archived_file,
task_linked=task_linked, task_linked=task_linked,
reconciliation_class=classification.reconciliation_class, reconciliation_class=classification.reconciliation_class,
reason=classification.reason, reason=classification.reason,
follow_up=classification.follow_up, 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 task_linked: bool | None = None
tasks_terminal: bool | None = None tasks_terminal: bool | None = None
blocking_reason: str | None = None blocking_reason: str | None = None
apply: bool = False
class StateChangeResponse(BaseModel): class StateChangeResponse(BaseModel):
@@ -36,4 +37,5 @@ class StateChangeResponse(BaseModel):
reconciliation_class: ReconciliationClass reconciliation_class: ReconciliationClass
reason: str reason: str
follow_up: 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 {}

View File

@@ -28,10 +28,12 @@ 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"): async def _create_workstream(client, topic_id, slug="test-ws", title="Test WS", status="active", **extra):
r = await client.post("/workstreams/", json={ payload = {
"topic_id": topic_id, "slug": slug, "title": title, "status": status, "topic_id": topic_id, "slug": slug, "title": title, "status": status,
}) }
payload.update(extra)
r = await client.post("/workstreams/", json=payload)
assert r.status_code == 201, r.text assert r.status_code == 201, r.text
return r.json() return r.json()
@@ -44,6 +46,17 @@ async def _create_task(client, workstream_id, title="Test task"):
return r.json() return r.json()
async def _create_repo(client, domain_slug="testdomain", slug="test-repo", local_path=None):
r = await client.post("/repos/", json={
"domain_slug": domain_slug,
"slug": slug,
"name": "Test Repo",
"local_path": str(local_path) if local_path else None,
})
assert r.status_code == 201, r.text
return r.json()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Domain tests # Domain tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -464,3 +477,113 @@ class TestReconciliationEndpoints:
}) })
assert r.status_code == 404 assert r.status_code == 404
async def test_apply_workstream_write_through_patches_file_then_db(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: active\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"actor": "dashboard",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "applied"
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
assert "status: backlog" in wp.read_text(encoding="utf-8")
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "backlog"
async def test_apply_workstream_without_file_does_not_mutate_db(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"file_backed": True,
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["file_backed"] is False
assert body["reconciliation_class"] == "deferred"
assert body["write_through_result"] == "not_applicable"
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active"
async def test_apply_task_write_through_patches_task_block_then_db(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
task = await _create_task(client, ws["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: active\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n\n"
"## Demo Task\n\n"
"```task\n"
"id: STATE-WP-9999-T01\n"
"status: todo\n"
"priority: high\n"
f"state_hub_task_id: \"{task['id']}\"\n"
"```\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"actor": "dashboard",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "applied"
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
assert "status: in_progress" in wp.read_text(encoding="utf-8")
r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "in_progress"

View File

@@ -72,7 +72,7 @@ pin the write-through, deferred, and human-confirmation decisions.
```task ```task
id: STATE-WP-0048-T02 id: STATE-WP-0048-T02
status: in_progress status: done
priority: high priority: high
state_hub_task_id: "50c20ddf-f039-418b-a763-7a8f581be5b0" state_hub_task_id: "50c20ddf-f039-418b-a763-7a8f581be5b0"
``` ```
@@ -90,11 +90,15 @@ current/target status, file-backed flags, reconciliation class, reason,
follow-up action, and `write_through_result: not_attempted`. Dashboard wiring follow-up action, and `write_through_result: not_attempted`. Dashboard wiring
and write-through execution remain for the next slice. and write-through execution remain for the next slice.
Result 2026-05-23: the API contract now also supports `apply: true` for safe
write-through changes. Unsafe classifications return `not_applicable` instead
of mutating files or DB state.
## T03 - Implement File Write-Through For Safe Changes ## T03 - Implement File Write-Through For Safe Changes
```task ```task
id: STATE-WP-0048-T03 id: STATE-WP-0048-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "c0a4e976-81fb-4fe3-a8a9-b8262e2c1c85" state_hub_task_id: "c0a4e976-81fb-4fe3-a8a9-b8262e2c1c85"
``` ```
@@ -106,6 +110,11 @@ the repo file, then sync the DB. Preserve formatting and existing
Done when common dashboard transitions such as `active -> backlog` or task Done when common dashboard transitions such as `active -> backlog` or task
status updates can update the file representation deterministically. status updates can update the file representation deterministically.
Result 2026-05-23: added workplan-file helpers and write-through support for
file-backed workstream status and linked task status changes. The endpoint
patches the workplan file first, then updates the DB status, and returns the
relative workplan path plus `write_through_result: applied`.
## T04 - Queue Reconciliation For Deferred Changes ## T04 - Queue Reconciliation For Deferred Changes
```task ```task