feat(RMGR-WP-0002): dual-run writeback, flags, meter, SH facade
Add dual-run flags/meter, harden task-status (idempotency, UUID, head, push), State Hub adapter for PATCH /tasks and C-15/reconcile proxy, pilot evidence, and finish RMGR-WP-0002.
This commit is contained in:
parent
bb1d030257
commit
310b43079d
14 changed files with 679 additions and 50 deletions
|
|
@ -1,4 +1,4 @@
|
|||
"""Governed command: repo.work.update_task_status (vertical-slice implementation)."""
|
||||
"""Governed command: repo.work.update_task_status (dual-run hardened)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -8,8 +8,9 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha
|
||||
from repo_manager.index_store import append_event, default_index_path, load_index, save_index
|
||||
from repo_manager import dual_run, idempotency
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block
|
||||
|
||||
|
|
@ -35,14 +36,38 @@ class CommandResult:
|
|||
return out
|
||||
|
||||
|
||||
def _patch_task_status_in_file(path: Path, task_canonical_id: str, status: str) -> bool:
|
||||
def _is_uuid(value: str) -> bool:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _patch_task_status_in_file(
|
||||
path: Path,
|
||||
*,
|
||||
canonical_id: str | None,
|
||||
hub_uuid: str | None,
|
||||
status: str,
|
||||
) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
block = match.group(0)
|
||||
meta = _parse_yaml_block(match.group(1))
|
||||
tid = meta.get("id")
|
||||
if tid is None or str(tid) != task_canonical_id:
|
||||
tid = str(meta["id"]) if meta.get("id") is not None else None
|
||||
hub = (
|
||||
str(meta["state_hub_task_id"]).strip().strip('"')
|
||||
if meta.get("state_hub_task_id") is not None
|
||||
else None
|
||||
)
|
||||
matched = False
|
||||
if canonical_id and tid == canonical_id:
|
||||
matched = True
|
||||
if hub_uuid and hub and hub.lower() == hub_uuid.lower():
|
||||
matched = True
|
||||
if not matched:
|
||||
return block
|
||||
replaced = re.sub(
|
||||
r"^(status:\s*)\S+",
|
||||
|
|
@ -53,7 +78,6 @@ def _patch_task_status_in_file(path: Path, task_canonical_id: str, status: str)
|
|||
)
|
||||
if replaced != block:
|
||||
return replaced
|
||||
# insert status if missing
|
||||
return block.replace("\n```", f"\nstatus: {status}\n```", 1)
|
||||
|
||||
new_text = _TASK_BLOCK_RE.sub(_replace, text)
|
||||
|
|
@ -71,10 +95,48 @@ def update_task_status(
|
|||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr command",
|
||||
commit: bool = True,
|
||||
push: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Apply task status change to the workplan file and optionally git-commit."""
|
||||
"""Apply task status change to the workplan file and optionally git-commit.
|
||||
|
||||
``task_id`` may be a canonical id (e.g. RMGR-WP-0001-T05) or a State Hub
|
||||
task UUID (matched against ``state_hub_task_id`` in the file).
|
||||
"""
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
repo_root = repo_root.resolve()
|
||||
payload = {
|
||||
"repo_root": str(repo_root),
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
}
|
||||
ph = idempotency.payload_hash(payload)
|
||||
|
||||
if idempotency_key:
|
||||
prior = idempotency.get(idempotency_key)
|
||||
if prior:
|
||||
if prior.get("payload_hash") != ph:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected"},
|
||||
error={
|
||||
"code": "conflict",
|
||||
"message": "idempotency key reused with different payload",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
# Replay prior result
|
||||
prev = prior.get("result") or {}
|
||||
return CommandResult(
|
||||
status=prev.get("status", "applied"),
|
||||
correlation_id=prev.get("correlation_id", correlation_id),
|
||||
evidence=prev.get("evidence") or {"status": "applied", "replay": True},
|
||||
error=prev.get("error"),
|
||||
)
|
||||
|
||||
if status not in VALID_TASK_STATUSES:
|
||||
return CommandResult(
|
||||
|
|
@ -88,12 +150,37 @@ def update_task_status(
|
|||
},
|
||||
)
|
||||
|
||||
# Locate task via fresh observation
|
||||
_snapshot, index = observe_repository(repo_root)
|
||||
match = next(
|
||||
(r for r in index.work_records if r.kind == "task" and r.id == task_id),
|
||||
None,
|
||||
)
|
||||
current_head = head_sha(repo_root)
|
||||
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected", "head_sha": current_head},
|
||||
error={
|
||||
"code": "precondition_failed",
|
||||
"message": f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||
"correlation_id": correlation_id,
|
||||
"retryable": True,
|
||||
},
|
||||
)
|
||||
|
||||
snapshot, index = observe_repository(repo_root, slug=repo_slug)
|
||||
slug = repo_slug or snapshot.get("slug")
|
||||
|
||||
hub_uuid = task_id if _is_uuid(task_id) else None
|
||||
canonical = None if hub_uuid else task_id
|
||||
|
||||
match = None
|
||||
for r in index.work_records:
|
||||
if r.kind != "task":
|
||||
continue
|
||||
if canonical and r.id == canonical:
|
||||
match = r
|
||||
break
|
||||
if hub_uuid and r.uuid and r.uuid.lower() == hub_uuid.lower():
|
||||
match = r
|
||||
break
|
||||
|
||||
if match is None:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
|
|
@ -119,8 +206,29 @@ def update_task_status(
|
|||
},
|
||||
)
|
||||
|
||||
expected_head = head_sha(repo_root)
|
||||
changed = _patch_task_status_in_file(path, task_id, status)
|
||||
# Already at status → treat as successful no-op applied with current head
|
||||
if match.status == status:
|
||||
result = CommandResult(
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence={
|
||||
"status": "applied",
|
||||
"git_sha": current_head,
|
||||
"files_touched": [],
|
||||
"noop": True,
|
||||
"observed_at": index.observed_at,
|
||||
},
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, ph, result.to_dict())
|
||||
return result
|
||||
|
||||
changed = _patch_task_status_in_file(
|
||||
path,
|
||||
canonical_id=match.id,
|
||||
hub_uuid=match.uuid or hub_uuid,
|
||||
status=status,
|
||||
)
|
||||
if not changed:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
|
|
@ -128,20 +236,23 @@ def update_task_status(
|
|||
evidence={"status": "rejected", "files_touched": []},
|
||||
error={
|
||||
"code": "conflict",
|
||||
"message": "task block not patched (already at status or id mismatch)",
|
||||
"message": "task block not patched (id mismatch)",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
git_sha: str | None = None
|
||||
push_ok: bool | None = None
|
||||
push_msg: str | None = None
|
||||
if commit:
|
||||
try:
|
||||
git_sha = commit_paths(
|
||||
repo_root,
|
||||
[match.source_path],
|
||||
message=(
|
||||
f"repo.work.update_task_status {task_id} -> {status}\n\n"
|
||||
f"repo.work.update_task_status {match.id or task_id} -> {status}\n\n"
|
||||
f"correlation_id: {correlation_id}\nreason: {reason}\n"
|
||||
f"source: repo-manager\n"
|
||||
),
|
||||
)
|
||||
except GitError as exc:
|
||||
|
|
@ -159,18 +270,21 @@ def update_task_status(
|
|||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
if push:
|
||||
push_ok, push_msg = push_ff(repo_root)
|
||||
|
||||
# Rebuild projection
|
||||
_snap2, index2 = observe_repository(repo_root)
|
||||
_snap2, index2 = observe_repository(repo_root, slug=slug)
|
||||
event = {
|
||||
"type": "repo.command.applied",
|
||||
"command": "repo.work.update_task_status",
|
||||
"correlation_id": correlation_id,
|
||||
"task_id": task_id,
|
||||
"task_id": match.id,
|
||||
"task_uuid": match.uuid,
|
||||
"new_status": status,
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [match.source_path],
|
||||
"expected_head_sha_before": expected_head,
|
||||
"expected_head_sha_before": current_head,
|
||||
"source": "repo-manager",
|
||||
}
|
||||
append_event(index2, event)
|
||||
append_event(
|
||||
|
|
@ -179,21 +293,37 @@ def update_task_status(
|
|||
"type": "repo.work.indexed",
|
||||
"correlation_id": correlation_id,
|
||||
"kind": "task",
|
||||
"id": task_id,
|
||||
"id": match.id,
|
||||
"source_path": match.source_path,
|
||||
},
|
||||
)
|
||||
save_index(index2)
|
||||
|
||||
evidence = {
|
||||
dual_run.record_mutation(
|
||||
source="repo-manager",
|
||||
kind="task_status_writeback",
|
||||
repo_slug=slug,
|
||||
detail={
|
||||
"task_id": match.id,
|
||||
"status": status,
|
||||
"git_sha": git_sha,
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
evidence: dict[str, Any] = {
|
||||
"status": "applied",
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [match.source_path],
|
||||
"observed_at": index2.observed_at,
|
||||
"index_path": str(default_index_path(repo_root)),
|
||||
"source": "repo-manager",
|
||||
}
|
||||
if not git_sha:
|
||||
# contract: file-mutating applied without git_sha is invalid — force fail if no commit
|
||||
if push:
|
||||
evidence["push_ok"] = push_ok
|
||||
evidence["push_message"] = push_msg
|
||||
|
||||
if commit and not git_sha:
|
||||
return CommandResult(
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
|
|
@ -205,8 +335,11 @@ def update_task_status(
|
|||
},
|
||||
)
|
||||
|
||||
return CommandResult(
|
||||
result = CommandResult(
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, ph, result.to_dict())
|
||||
return result
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue