feat: add governed fast work-record sync

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
tegwick 2026-08-30 22:38:54 +02:00
parent 7ea7690dfc
commit 58414404d6
10 changed files with 1280 additions and 32 deletions

View file

@ -6,6 +6,7 @@ import argparse
import json
import os
import sys
from datetime import date
from pathlib import Path
from repo_manager.commands.rapp import add_rapp_parser
@ -129,6 +130,67 @@ def main(argv: list[str] | None = None) -> int:
help="Patch file only (invalid as full applied evidence; for tests)",
)
p_task = sub.add_parser("task", help="Governed file-backed task mutations")
task_sub = p_task.add_subparsers(dest="task_command")
p_task_add = task_sub.add_parser("add", help="Append a task to a workplan")
p_task_add.add_argument("--path", default=".")
p_task_add.add_argument("--workplan-id", required=True)
p_task_add.add_argument("--task-id", default=None)
p_task_add.add_argument("--title", required=True)
p_task_add.add_argument("--description", required=True)
p_task_add.add_argument("--status", choices=["wait", "todo", "progress", "done", "cancel"], default="todo")
p_task_add.add_argument("--priority", choices=["low", "medium", "high", "critical"], default="medium")
p_task_add.add_argument("--needs-human", action="store_true")
p_task_add.add_argument("--intervention-note", default=None)
p_task_add.add_argument("--blocking-reason", default=None)
p_task_update = task_sub.add_parser("update", help="Update task metadata and lifecycle")
p_task_update.add_argument("--path", default=".")
p_task_update.add_argument("--task-id", required=True)
p_task_update.add_argument("--title", default=None)
p_task_update.add_argument("--description", default=None)
p_task_update.add_argument("--status", choices=["wait", "todo", "progress", "done", "cancel"], default=None)
p_task_update.add_argument("--priority", choices=["low", "medium", "high", "critical"], default=None)
p_task_update.add_argument("--needs-human", action=argparse.BooleanOptionalAction, default=None)
p_task_update.add_argument("--intervention-note", default=None)
p_task_update.add_argument("--blocking-reason", default=None)
for task_parser in (p_task_add, p_task_update):
task_parser.add_argument("--reason", default="rmgr CLI")
task_parser.add_argument("--correlation-id", default=None)
task_parser.add_argument("--idempotency-key", default=None)
task_parser.add_argument("--expected-head-sha", default=None)
task_parser.add_argument("--slug", default=None)
task_parser.add_argument("--push", action="store_true")
task_parser.add_argument("--no-commit", action="store_true")
p_adhoc = sub.add_parser("adhoc", help="Daily repository-qualified ad-hoc work")
adhoc_sub = p_adhoc.add_subparsers(dest="adhoc_command")
p_adhoc_add = adhoc_sub.add_parser("add", help="Create/reuse today's ad-hoc and append a task")
p_adhoc_add.add_argument("--path", default=".")
p_adhoc_add.add_argument("--title", required=True)
p_adhoc_add.add_argument("--description", required=True)
p_adhoc_add.add_argument("--prefix", default=None)
p_adhoc_add.add_argument("--date", type=date.fromisoformat, default=None)
p_adhoc_add.add_argument("--status", choices=["wait", "todo", "progress", "done", "cancel"], default="todo")
p_adhoc_add.add_argument("--priority", choices=["low", "medium", "high", "critical"], default="medium")
p_adhoc_add.add_argument("--owner", default="codex")
p_adhoc_add.add_argument("--reason", default="rmgr CLI")
p_adhoc_add.add_argument("--correlation-id", default=None)
p_adhoc_add.add_argument("--idempotency-key", default=None)
p_adhoc_add.add_argument("--expected-head-sha", default=None)
p_adhoc_add.add_argument("--slug", default=None)
p_adhoc_add.add_argument("--push", action="store_true")
p_adhoc_add.add_argument("--no-commit", action="store_true")
p_sync = sub.add_parser("sync", help="Fast authoritative forge projection reconciliation")
p_sync.add_argument("--path", default=".")
p_sync.add_argument("--slug", default=None)
p_sync.add_argument("--api-base", default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"))
p_sync.add_argument("--expected-instance-label", default="railliance01")
p_sync.add_argument("--push", action="store_true", help="Push ahead commits before reconciliation")
p_sync.add_argument("--no-commit-identifiers", action="store_true")
p_sync.add_argument("--acknowledge-retirements", action="store_true")
p_sync.add_argument("--no-queue", action="store_true")
p_wp = sub.add_parser("workplan", help="Governed file-backed workplan mutations")
wp_sub = p_wp.add_subparsers(dest="workplan_command")
p_wp_create = wp_sub.add_parser("create", help="Create a workplan file")
@ -581,6 +643,77 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1
if args.command == "task":
if not args.task_command:
p_task.print_help()
return 2
from repo_manager.commands.task import mutate_task
result = mutate_task(
Path(args.path),
args.task_id,
operation=args.task_command,
workplan_id=getattr(args, "workplan_id", None),
title=args.title,
description=args.description,
status=args.status,
priority=args.priority,
needs_human=args.needs_human,
intervention_note=args.intervention_note,
blocking_reason=args.blocking_reason,
correlation_id=args.correlation_id,
reason=args.reason,
commit=not args.no_commit,
push=args.push,
expected_head_sha=args.expected_head_sha,
idempotency_key=args.idempotency_key,
repo_slug=args.slug,
)
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1
if args.command == "adhoc":
if args.adhoc_command != "add":
p_adhoc.print_help()
return 2
from repo_manager.commands.task import add_adhoc_task
result = add_adhoc_task(
Path(args.path),
args.title,
args.description,
prefix=args.prefix,
on_date=args.date,
status=args.status,
priority=args.priority,
owner=args.owner,
correlation_id=args.correlation_id,
reason=args.reason,
commit=not args.no_commit,
push=args.push,
expected_head_sha=args.expected_head_sha,
idempotency_key=args.idempotency_key,
repo_slug=args.slug,
)
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1
if args.command == "sync":
from repo_manager.projection_sync import sync_repository_projection
result = sync_repository_projection(
Path(args.path),
api_base=args.api_base,
repo_slug=args.slug,
expected_instance_label=args.expected_instance_label,
commit_identifiers=not args.no_commit_identifiers,
push=args.push,
acknowledge_retirements=args.acknowledge_retirements,
queue_on_unavailable=not args.no_queue,
)
print(json.dumps(result, indent=2))
return 0 if result.get("ok") else 1
if args.command == "workplan":
from repo_manager.commands.workplan import mutate_workplan

View file

@ -0,0 +1,424 @@
"""Governed file-backed task and daily ad-hoc mutations."""
from __future__ import annotations
import json
import re
import uuid
from datetime import date
from pathlib import Path
from typing import Any
from repo_manager import dual_run, idempotency
from repo_manager.commands.workplan import CommandResult, create_workplan
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
from repo_manager.identifiers import derive_work_record_uuid, load_fleet_namespace
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
from repo_manager.record_identity import classify_record_id
from repo_manager.time import utc_today
VALID_TASK_STATUSES = frozenset({"wait", "todo", "progress", "done", "cancel"})
VALID_TASK_PRIORITIES = frozenset({"low", "medium", "high", "critical"})
def _result(
command: str,
status: str,
correlation_id: str,
*,
evidence: dict[str, Any] | None = None,
code: str | None = None,
message: str | None = None,
) -> CommandResult:
error = None
if code and message:
error = {"code": code, "message": message, "correlation_id": correlation_id}
return CommandResult(
command=command,
status=status,
correlation_id=correlation_id,
evidence=evidence or {"status": status},
error=error,
)
def _is_uuid(value: str) -> bool:
try:
uuid.UUID(value)
return True
except ValueError:
return False
def _find_record(index: Any, kind: str, identifier: str) -> Any | None:
by_uuid = _is_uuid(identifier)
for record in index.work_records:
if record.kind != kind:
continue
if not by_uuid and record.id == identifier:
return record
if by_uuid and record.uuid and record.uuid.lower() == identifier.lower():
return record
return None
def _render_scalar(key: str, value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if key in {"status", "priority"}:
return str(value)
return json.dumps(str(value), ensure_ascii=False)
def _patch_task_fields(
text: str,
*,
canonical_id: str,
updates: dict[str, Any],
) -> tuple[str, bool]:
matches = 0
changed = False
def replace(match: re.Match[str]) -> str:
nonlocal matches, changed
raw = match.group(1)
meta = _parse_yaml_block(raw.strip())
if str(meta.get("id") or "") != canonical_id:
return match.group(0)
matches += 1
patched = raw
for key, value in updates.items():
rendered = _render_scalar(key, value)
pattern = re.compile(rf"^(?P<prefix>{re.escape(key)}:\s*).*$", re.MULTILINE)
if pattern.search(patched):
candidate = pattern.sub(rf"\g<prefix>{rendered}", patched, count=1)
else:
candidate = f"{patched.rstrip()}\n{key}: {rendered}\n"
if candidate != patched:
changed = True
patched = candidate
return f"```task\n{patched.strip()}\n```"
result = _TASK_BLOCK_RE.sub(replace, text)
if matches != 1:
raise ValueError(f"{canonical_id}: expected exactly one task block, found {matches}")
return result, changed
def _next_task_id(parent_id: str, index: Any) -> str:
pattern = re.compile(rf"^{re.escape(parent_id)}-T(?P<number>[0-9]{{2,3}})$")
numbers = [
int(match.group("number"))
for record in index.work_records
if record.kind == "task" and record.id and (match := pattern.fullmatch(record.id))
]
number = max(numbers, default=0) + 1
width = 2 if number < 100 else 3
candidate = f"{parent_id}-T{number:0{width}d}"
if classify_record_id("task", candidate) != "canonical":
raise ValueError(f"no canonical task identifier remains under {parent_id}")
return candidate
def mutate_task(
repo_root: Path,
task_id: str | None,
*,
operation: str,
workplan_id: str | None = None,
title: str | None = None,
description: str | None = None,
status: str | None = None,
priority: str | None = None,
needs_human: bool | None = None,
intervention_note: str | None = None,
blocking_reason: str | None = None,
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:
command = f"repo.work.{operation}_task"
correlation_id = correlation_id or str(uuid.uuid4())
repo_root = repo_root.resolve()
payload = {
"repo_root": str(repo_root),
"task_id": task_id,
"operation": operation,
"workplan_id": workplan_id,
"title": title,
"description": description,
"status": status,
"priority": priority,
"needs_human": needs_human,
"intervention_note": intervention_note,
"blocking_reason": blocking_reason,
"expected_head_sha": expected_head_sha,
}
payload_hash = idempotency.payload_hash(payload)
if idempotency_key and (prior := idempotency.get(idempotency_key)):
if prior.get("payload_hash") != payload_hash:
return _result(
command, "rejected", correlation_id, code="conflict",
message="idempotency key reused with different payload",
)
previous = prior.get("result") or {}
return CommandResult(
command=previous.get("command", command),
status=previous.get("status", "applied"),
correlation_id=previous.get("correlation_id", correlation_id),
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
error=previous.get("error"),
)
if operation not in {"add", "update"}:
return _result(command, "rejected", correlation_id, code="validation_error", message="invalid task operation")
if status is not None and status not in VALID_TASK_STATUSES:
return _result(command, "rejected", correlation_id, code="validation_error", message=f"invalid task status {status!r}")
if priority is not None and priority not in VALID_TASK_PRIORITIES:
return _result(command, "rejected", correlation_id, code="validation_error", message=f"invalid task priority {priority!r}")
if needs_human is True and not intervention_note:
return _result(command, "rejected", correlation_id, code="validation_error", message="intervention_note is required when needs_human is true")
current_head = head_sha(repo_root)
if expected_head_sha and current_head and expected_head_sha != current_head:
result = _result(
command, "rejected", correlation_id,
evidence={"status": "rejected", "head_sha": current_head},
code="precondition_failed",
message=f"expected_head_sha {expected_head_sha} != {current_head}",
)
if result.error:
result.error["retryable"] = True
return result
snapshot, index = observe_repository(repo_root, slug=repo_slug)
slug = repo_slug or snapshot.get("slug")
if operation == "add":
if not workplan_id:
return _result(command, "rejected", correlation_id, code="validation_error", message="add requires workplan_id")
parent = _find_record(index, "workplan", workplan_id)
if parent is None or not parent.id:
return _result(command, "rejected", correlation_id, code="not_found", message=f"workplan {workplan_id!r} not found")
if not title or not title.strip():
return _result(command, "rejected", correlation_id, code="validation_error", message="add requires a title")
canonical_id = task_id or _next_task_id(parent.id, index)
if classify_record_id("task", canonical_id) != "canonical":
return _result(command, "rejected", correlation_id, code="validation_error", message=f"invalid canonical task id {canonical_id!r}")
if _find_record(index, "task", canonical_id) is not None:
return _result(command, "rejected", correlation_id, code="conflict", message=f"task {canonical_id!r} exists")
if not canonical_id.startswith(parent.id + "-T"):
return _result(command, "rejected", correlation_id, code="validation_error", message="task id must be qualified by its workplan id")
path = repo_root / parent.source_path
task_uuid = derive_work_record_uuid(load_fleet_namespace(), canonical_id)
task_status = status or "todo"
task_priority = priority or "medium"
fields = [
f"id: {canonical_id}",
f"status: {task_status}",
f"priority: {task_priority}",
f'state_hub_task_id: "{task_uuid}"',
]
if needs_human is not None:
fields.append(f"needs_human: {'true' if needs_human else 'false'}")
if intervention_note:
fields.append(f"intervention_note: {json.dumps(intervention_note, ensure_ascii=False)}")
if blocking_reason:
fields.append(f"blocking_reason: {json.dumps(blocking_reason, ensure_ascii=False)}")
addition = (
f"\n\n## {title.strip()}\n\n```task\n"
+ "\n".join(fields)
+ "\n```\n\n"
+ ((description or "").strip() or "Task details to be completed.")
+ "\n"
)
path.write_text(path.read_text(encoding="utf-8").rstrip() + addition, encoding="utf-8")
changes = {"operation": "add", "task_id": canonical_id, "workplan_id": parent.id}
else:
if not task_id:
return _result(command, "rejected", correlation_id, code="validation_error", message="update requires task_id")
record = _find_record(index, "task", task_id)
if record is None or not record.id:
return _result(command, "rejected", correlation_id, code="not_found", message=f"task {task_id!r} not found")
updates = {
key: value
for key, value in {
"title": title.strip() if title else None,
"description": description.strip() if description else None,
"status": status,
"priority": priority,
"needs_human": needs_human,
"intervention_note": intervention_note,
"blocking_reason": blocking_reason,
}.items()
if value is not None
}
if not updates:
return _result(command, "rejected", correlation_id, code="validation_error", message="update requires at least one field")
existing_human = bool(record.extra.get("needs_human"))
resulting_human = updates.get("needs_human", existing_human)
resulting_note = updates.get("intervention_note", record.extra.get("intervention_note"))
if resulting_human and not resulting_note:
return _result(command, "rejected", correlation_id, code="validation_error", message="intervention_note is required when needs_human is true")
path = repo_root / record.source_path
patched, changed = _patch_task_fields(
path.read_text(encoding="utf-8"), canonical_id=record.id, updates=updates
)
if not changed:
result = _result(
command, "applied", correlation_id,
evidence={"status": "applied", "git_sha": current_head, "files_touched": [], "noop": True},
)
if idempotency_key:
idempotency.put(idempotency_key, payload_hash, result.to_dict())
return result
path.write_text(patched, encoding="utf-8")
canonical_id = record.id
changes = {"operation": "update", "task_id": canonical_id, "fields": sorted(updates)}
relative_path = str(path.relative_to(repo_root))
git_sha = None
push_ok = None
push_message = None
if commit:
try:
git_sha = commit_paths(
repo_root,
[relative_path],
message=(
f"{command} {canonical_id}\n\ncorrelation_id: {correlation_id}\n"
f"reason: {reason}\nsource: repo-manager\n"
),
)
except GitError as exc:
return _result(command, "failed", correlation_id, evidence={"status": "failed", "files_touched": [relative_path]}, code="internal", message=str(exc))
if push:
push_ok, push_message = push_ff(repo_root)
_snapshot_after, index_after = observe_repository(repo_root, slug=slug)
append_event(
index_after,
{
"type": "repo.command.applied",
"command": command,
"correlation_id": correlation_id,
"git_sha": git_sha,
**changes,
},
)
save_index(index_after)
dual_run.record_mutation(
source="repo-manager",
kind=f"task_{operation}",
repo_slug=slug,
detail={**changes, "git_sha": git_sha, "correlation_id": correlation_id},
)
evidence = {
"status": "applied",
"task_id": canonical_id,
"git_sha": git_sha,
"files_touched": [relative_path],
"index_path": str(default_index_path(repo_root)),
"source": "repo-manager",
}
if push:
evidence.update({"push_ok": push_ok, "push_message": push_message})
result = _result(command, "applied", correlation_id, evidence=evidence)
if idempotency_key:
idempotency.put(idempotency_key, payload_hash, result.to_dict())
return result
def add_adhoc_task(
repo_root: Path,
title: str,
description: str,
*,
prefix: str | None = None,
on_date: date | None = None,
status: str = "todo",
priority: str = "medium",
owner: str = "codex",
correlation_id: str | None = None,
reason: str = "rmgr adhoc add",
commit: bool = True,
push: bool = False,
expected_head_sha: str | None = None,
idempotency_key: str | None = None,
repo_slug: str | None = None,
) -> CommandResult:
"""Create/reuse today's qualified ad-hoc workplan and append one task."""
correlation_id = correlation_id or str(uuid.uuid4())
repo_root = repo_root.resolve()
payload = {
"repo_root": str(repo_root), "title": title, "description": description,
"prefix": prefix, "on_date": str(on_date) if on_date else None,
"status": status, "priority": priority, "expected_head_sha": expected_head_sha,
}
payload_hash = idempotency.payload_hash(payload)
if idempotency_key and (prior := idempotency.get(idempotency_key)):
if prior.get("payload_hash") != payload_hash:
return _result("repo.work.add_adhoc_task", "rejected", correlation_id, code="conflict", message="idempotency key reused with different payload")
previous = prior.get("result") or {}
return CommandResult(
command=previous.get("command", "repo.work.add_adhoc_task"),
status=previous.get("status", "applied"),
correlation_id=previous.get("correlation_id", correlation_id),
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
error=previous.get("error"),
)
snapshot, index = observe_repository(repo_root, slug=repo_slug)
prefixes = {
record.id.split("-WP-", 1)[0]
for record in index.work_records
if record.kind == "workplan" and record.id and "-WP-" in record.id
}
selected = (prefix or "").strip().upper().removesuffix("-WP")
if not selected:
if len(prefixes) != 1:
return _result("repo.work.add_adhoc_task", "rejected", correlation_id, code="validation_error", message=f"could not infer one workplan prefix; candidates={sorted(prefixes)!r}; pass --prefix")
selected = next(iter(prefixes))
day = on_date or utc_today()
workplan_id = f"{selected}-WP-ADHOC-{day.isoformat()}"
parent = _find_record(index, "workplan", workplan_id)
if parent is None:
created = create_workplan(
repo_root,
workplan_id,
f"Ad Hoc — {day.isoformat()}",
f"Small opportunistic fixes completed on {day.isoformat()}.",
owner=owner,
status="active",
commit=False,
expected_head_sha=expected_head_sha,
repo_slug=repo_slug or snapshot.get("slug"),
)
if created.status != "applied":
return created
result = mutate_task(
repo_root,
None,
operation="add",
workplan_id=workplan_id,
title=title,
description=description,
status=status,
priority=priority,
correlation_id=correlation_id,
reason=reason,
commit=commit,
push=push,
expected_head_sha=expected_head_sha,
repo_slug=repo_slug or snapshot.get("slug"),
)
result.command = "repo.work.add_adhoc_task"
result.evidence["workplan_id"] = workplan_id
if idempotency_key and result.status == "applied":
idempotency.put(idempotency_key, payload_hash, result.to_dict())
return result

View file

@ -12,14 +12,15 @@ from typing import Any
from repo_manager import dual_run, idempotency
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
from repo_manager.identifiers import derive_work_record_uuid, load_fleet_namespace
from repo_manager.index_store import append_event, default_index_path, save_index
from repo_manager.observe import observe_repository
from repo_manager.record_identity import classify_record_id
from repo_manager.time import utc_today
VALID_WORKPLAN_STATUSES = frozenset(
{"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"}
)
_WORKPLAN_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-WP-[0-9]{4,}$")
_FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P<meta>.*?)\r?\n---(?P<body>\r?\n.*)?\Z", re.DOTALL)
@ -123,6 +124,7 @@ def _create_text(
topic_slug: str,
) -> str:
today = _today().isoformat()
workplan_uuid = derive_work_record_uuid(load_fleet_namespace(), workplan_id)
return (
"---\n"
f"id: {workplan_id}\n"
@ -135,6 +137,7 @@ def _create_text(
f"topic_slug: {topic_slug}\n"
f"created: {_quoted(today)}\n"
f"updated: {_quoted(today)}\n"
f"state_hub_workstream_id: {_quoted(str(workplan_uuid))}\n"
"---\n\n"
f"# {title}\n\n"
"## Goal\n\n"
@ -241,7 +244,7 @@ def mutate_workplan(
changes: dict[str, Any]
if operation == "create":
if not _WORKPLAN_ID_RE.fullmatch(workplan_id):
if classify_record_id("workplan", workplan_id) != "canonical":
return _rejected(
command,
correlation_id,
@ -265,8 +268,23 @@ def mutate_workplan(
"validation_error",
f"invalid workplan status {create_status!r}",
)
name = filename or f"{workplan_id}-{_slugify(title)}.md"
if Path(name).name != name or not name.endswith(".md") or not name.startswith(f"{workplan_id}-"):
is_adhoc = "-WP-ADHOC-" in workplan_id
adhoc_date = workplan_id.rsplit("-WP-ADHOC-", 1)[-1]
name = filename or (
f"ADHOC-{adhoc_date}.md"
if is_adhoc
else f"{workplan_id}-{_slugify(title)}.md"
)
expected_adhoc_name = f"ADHOC-{adhoc_date}.md"
safe_name = (
Path(name).name == name
and name.endswith(".md")
and (
name.startswith(f"{workplan_id}-")
or (is_adhoc and name == expected_adhoc_name)
)
)
if not safe_name:
return _rejected(command, correlation_id, "validation_error", "unsafe workplan filename")
path = repo_root / "workplans" / name
if path.exists():

View file

@ -25,6 +25,7 @@ from repo_manager.parse.workplan import (
parse_workplan_file,
)
from repo_manager.prefix_registry import iter_repo_roots
from repo_manager.record_identity import classify_record_id, scan_record_identities
from repo_manager.time import utc_now_text
DERIVATION_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
@ -33,7 +34,6 @@ FLEET_NAMESPACE_SCHEMA = "repo-manager.fleet-namespace.v1"
DEFAULT_NAMESPACE_CONTRACT = Path(__file__).resolve().parents[2] / "config" / "fleet-namespace.yaml"
LIVE_WORKPLAN_STATUSES = frozenset({"proposed", "ready", "active", "blocked", "backlog"})
_NAMESPACE_RE = re.compile(r"^[a-z0-9][a-z0-9.-]{0,62}$")
_RECORD_ID_RE = re.compile(r"^[A-Z][A-Z0-9-]*-WP-[0-9]{4}(?:-T[0-9]{2,})?$")
def load_fleet_namespace(path: Path = DEFAULT_NAMESPACE_CONTRACT) -> str:
@ -52,7 +52,15 @@ def derivation_name(namespace: str, identifier: str) -> str:
identifier = identifier.strip()
if not _NAMESPACE_RE.fullmatch(namespace):
raise ValueError("namespace must be lowercase DNS-label style")
if not _RECORD_ID_RE.fullmatch(identifier):
canonical_kind = next(
(
kind
for kind in ("workplan", "task")
if classify_record_id(kind, identifier) == "canonical"
),
None,
)
if canonical_kind is None:
raise ValueError("identifier must be a canonical workplan or task id")
return f"{namespace}\n{identifier}"
@ -732,6 +740,101 @@ def _atomic_write(path: Path, content: str) -> None:
os.unlink(temporary_name)
def ensure_missing_work_record_identifiers(
repo_root: Path,
*,
namespace: str | None = None,
execute: bool = True,
) -> dict[str, Any]:
"""Assign only missing canonical UUIDs, deterministically, in one repository.
Existing UUIDs are deliberately preserved. Replacing a legacy UUID is a
separately sealed migration; ordinary registration must never perform it.
Every source file is validated and rendered before any file is changed.
"""
repo_root = repo_root.resolve()
namespace = namespace or load_fleet_namespace()
identity = scan_record_identities(repo_root)
if identity["invalid_identifiers"] or identity["identity_collisions"]:
raise ValueError(
"work-record identities are not safe to derive: "
f"invalid={identity['invalid_identifiers']!r}, "
f"collisions={identity['identity_collisions']!r}"
)
rendered: dict[Path, str] = {}
assignments: list[dict[str, str]] = []
skipped: list[dict[str, str]] = []
for path in iter_workplan_files(repo_root):
parsed = parse_workplan_file(path, repo_root=repo_root)
text = path.read_text(encoding="utf-8")
changed = False
if parsed.id and not parsed.state_hub_workstream_id:
if classify_record_id("workplan", parsed.id) != "canonical":
skipped.append(
{"kind": "workplan", "record_id": parsed.id, "path": parsed.path}
)
else:
derived = str(derive_work_record_uuid(namespace, parsed.id))
text = _patch_workplan_identifier(
text,
record_id=parsed.id,
expected=None,
target=derived,
)
assignments.append(
{
"kind": "workplan",
"record_id": parsed.id,
"uuid": derived,
"path": parsed.path,
}
)
changed = True
for task in parsed.tasks:
if not task.id or task.state_hub_task_id:
continue
if classify_record_id("task", task.id) != "canonical":
skipped.append({"kind": "task", "record_id": task.id, "path": parsed.path})
continue
derived = str(derive_work_record_uuid(namespace, task.id))
text = _patch_task_identifier(
text,
record_id=task.id,
expected=None,
target=derived,
)
assignments.append(
{
"kind": "task",
"record_id": task.id,
"uuid": derived,
"path": parsed.path,
}
)
changed = True
if changed:
rendered[path] = text
if skipped:
raise ValueError(
"missing UUIDs on grandfathered or noncanonical records require "
f"an explicit identity decision: {skipped!r}"
)
if execute:
for path, content in rendered.items():
_atomic_write(path, content)
return {
"schema": "repo-manager.identifier-assignment.v1",
"ok": True,
"executed": execute,
"repo": repo_root.name,
"namespace": namespace,
"assignments": assignments,
"files_changed": sorted(str(path.relative_to(repo_root)) for path in rendered),
}
def migrate_repository_identifier_files(
plan: dict[str, Any],
*,

View file

@ -0,0 +1,239 @@
"""Fast, guarded trigger for State Hub's forge-derived repository projection."""
from __future__ import annotations
import json
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any
import httpx
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
from repo_manager.identifiers import ensure_missing_work_record_identifiers
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=False)
def _ahead_behind(repo: Path) -> dict[str, Any]:
upstream = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if upstream.returncode != 0:
raise ValueError("current branch has no upstream; central can only derive pushed work")
counts = _git(repo, "rev-list", "--left-right", "--count", "@{u}...HEAD")
if counts.returncode != 0:
raise ValueError(counts.stderr.strip() or "could not compare HEAD with upstream")
try:
behind, ahead = (int(value) for value in counts.stdout.split())
except ValueError as exc:
raise ValueError("could not parse Git ahead/behind state") from exc
return {"upstream": upstream.stdout.strip(), "behind": behind, "ahead": ahead}
def _dirty_authoritative_paths(repo: Path) -> list[str]:
result = _git(repo, "status", "--porcelain", "--", "workplans")
if result.returncode != 0:
raise ValueError(result.stderr.strip() or "could not inspect workplan files")
return [line[3:] for line in result.stdout.splitlines() if len(line) > 3]
def _pending_path(repo: Path) -> Path:
resolved = _git(
repo, "rev-parse", "--git-path", "repo-manager/pending-work-record-projection.json"
)
if resolved.returncode != 0 or not resolved.stdout.strip():
raise ValueError("could not resolve the repository-local Git state directory")
path = Path(resolved.stdout.strip())
return path if path.is_absolute() else repo / path
def _write_pending(repo: Path, payload: dict[str, Any]) -> Path:
path = _pending_path(repo)
path.parent.mkdir(parents=True, exist_ok=True)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as handle:
temporary = Path(handle.name)
handle.write(json.dumps(payload, indent=2) + "\n")
temporary.replace(path)
temporary = None
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return path
def sync_repository_projection(
repo_root: Path,
*,
api_base: str,
repo_slug: str | None = None,
expected_instance_label: str | None = "railliance01",
commit_identifiers: bool = True,
push: bool = False,
acknowledge_retirements: bool = False,
queue_on_unavailable: bool = True,
transport: httpx.BaseTransport | None = None,
) -> dict[str, Any]:
"""Assign missing IDs, prove the commit is pushed, then reconcile once."""
started = time.perf_counter()
repo_root = repo_root.resolve()
slug = repo_slug or repo_root.name
dirty = _dirty_authoritative_paths(repo_root)
if dirty:
return {
"ok": False,
"status": "pending_commit",
"error": "authoritative workplan files have uncommitted changes",
"dirty_paths": dirty,
}
assignments = ensure_missing_work_record_identifiers(repo_root)
if assignments["files_changed"] and commit_identifiers:
try:
commit_paths(
repo_root,
assignments["files_changed"],
message=(
"repo.work.assign_missing_identifiers\n\n"
"source: repo-manager\nreason: deterministic projection registration\n"
),
)
except GitError as exc:
return {"ok": False, "status": "failed", "error": str(exc), "identifiers": assignments}
dirty = _dirty_authoritative_paths(repo_root)
if dirty:
return {
"ok": False,
"status": "pending_commit",
"error": "authoritative workplan files have uncommitted changes",
"dirty_paths": dirty,
"identifiers": assignments,
}
try:
git = _ahead_behind(repo_root)
except ValueError as exc:
return {"ok": False, "status": "failed", "error": str(exc), "identifiers": assignments}
if git["behind"]:
return {
"ok": False,
"status": "behind_upstream",
"error": "branch is behind upstream; reconcile Git before projection",
"git": git,
}
if git["ahead"] and push:
pushed, message = push_ff(repo_root)
if not pushed:
return {
"ok": False,
"status": "push_failed",
"error": message,
"git": git,
}
git = _ahead_behind(repo_root)
if git["ahead"]:
return {
"ok": True,
"status": "pending_push",
"message": "commit is not yet visible to the authoritative forge",
"git": git,
"identifiers": assignments,
}
commit = head_sha(repo_root)
if not commit:
return {"ok": False, "status": "failed", "error": "repository HEAD is unavailable"}
pending = {
"schema": "repo-manager.pending-work-record-projection.v1",
"repo_slug": slug,
"expected_commit": commit,
"api_base": api_base.rstrip("/"),
}
headers = {
"Idempotency-Key": f"rmgr-projection:{slug}:{commit}",
"X-StateHub-Source-Agent": "repo-manager",
}
try:
with httpx.Client(
base_url=api_base.rstrip("/"),
timeout=httpx.Timeout(120.0, connect=5.0),
follow_redirects=True,
transport=transport,
) as client:
health = client.get("/state/health")
health.raise_for_status()
identity = health.json()
identity_ok = identity.get("instance_role") == "primary" and (
expected_instance_label is None
or identity.get("instance_label") == expected_instance_label
)
if not identity_ok:
if not queue_on_unavailable:
return {
"ok": False,
"status": "wrong_instance",
"instance": identity,
}
path = _write_pending(repo_root, {**pending, "reason": "wrong_instance"})
return {
"ok": True,
"status": "queued",
"reason": "wrong_instance",
"instance": identity,
"pending_path": str(path),
}
response = client.post(
f"/repos/{slug}/work-record-projection/reconcile",
json={
"expected_commit": commit,
"acknowledge_retirements": acknowledge_retirements,
},
headers=headers,
)
response.raise_for_status()
receipt = response.json()
except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as exc:
if not queue_on_unavailable:
return {"ok": False, "status": "unavailable", "error": str(exc)}
path = _write_pending(repo_root, {**pending, "reason": "hub_unavailable"})
return {
"ok": True,
"status": "queued",
"reason": "hub_unavailable",
"pending_path": str(path),
}
except httpx.HTTPStatusError as exc:
detail: Any
try:
detail = exc.response.json()
except ValueError:
detail = exc.response.text[:500]
return {
"ok": False,
"status": "rejected",
"http_status": exc.response.status_code,
"error": detail,
}
_pending_path(repo_root).unlink(missing_ok=True)
outcome_status = receipt.get("outcome", {}).get("status", "applied")
return {
"ok": outcome_status in {"applied", "noop"},
"status": outcome_status,
"repo_slug": slug,
"commit": commit,
"requests": 2,
"elapsed_ms": round((time.perf_counter() - started) * 1000, 1),
"git": git,
"identifiers": assignments,
"receipt": receipt,
}