ACTIVITY-WP-0043-T05: owner-return eligibility builder bound to scan revision
Build the planner's current_tasks from immutable Forge commit content at the C-25 scan SHA (no checkouts, ADR-002). Fails closed on revision mismatch, non-head scans, missing/malformed fields and duplicate identities; unresolved predecessors and decisions stay unknown (None). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 692292@bnt-lap001 Assistant-Session: 89c7d8e8-e1b3-47eb-9411-a0ab227fca2f
This commit is contained in:
parent
9741d5cc19
commit
c6df768ef0
4 changed files with 808 additions and 2 deletions
474
src/activity_core/owner_return_eligibility.py
Normal file
474
src/activity_core/owner_return_eligibility.py
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
"""Eligibility input builder for owner-return pickup (ACTIVITY-WP-0043-T05).
|
||||
|
||||
Builds the ``current_tasks`` mapping consumed by State Hub's offline planner
|
||||
(``state-hub/scripts/owner_return_handoff.py::plan_owner_returns``) from fresh
|
||||
authoritative source, per the contract in ``state-hub/docs/owner-return-handoff.md``
|
||||
("Current eligibility guard").
|
||||
|
||||
Source binding
|
||||
--------------
|
||||
The caller supplies ``scan_revision``: the full 40-hex commit SHA that the C-25
|
||||
consistency scan read. Every workplan file is read from the Forge at exactly
|
||||
that commit (``/contents`` and ``/raw`` with ``ref=<sha>``), i.e. immutable
|
||||
commit content -- never a workstation checkout (ADR-002) and never a branch
|
||||
name that could move between reads. Before reading, the builder verifies that
|
||||
the Forge knows the commit (``/git/commits/<sha>`` echoes the same SHA) and, by
|
||||
default, that it is still the default-branch HEAD (``require_current_head``):
|
||||
a scan of an older revision is stale and refuses rather than plan from it.
|
||||
|
||||
Resolution rules (fail closed)
|
||||
------------------------------
|
||||
* Missing or malformed authoritative fields (task ``status``, workplan
|
||||
``status``, non-boolean ``needs_human``, bad ``wait_until``, malformed
|
||||
``depends_on``) raise :class:`EligibilityError` -- no partial mapping.
|
||||
* Duplicate record identities at the revision raise (no winner is chosen).
|
||||
* Predecessors: task-block ``depends_on`` plus the owning workplan's
|
||||
frontmatter dependencies (a workplan-level gate applies to all its tasks).
|
||||
Targets are resolved from the same commit; cross-repository targets only
|
||||
through an explicit ``external_status`` resolver. A target that cannot be
|
||||
resolved is recorded as ``None`` (unknown) -- never dropped, never replaced
|
||||
by an empty list.
|
||||
* Decisions are hub-native records: ``decision_status`` comes from
|
||||
``decision_status`` (e.g. State Hub ``GET /decisions/{id}``); lookup failure
|
||||
yields ``None`` (unknown), which the planner stops on.
|
||||
|
||||
Nothing here writes, schedules or dispatches. The HTTP client is injected; the
|
||||
caller owns any read credential.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
SCHEMA = "activity-core.owner-return-eligibility.v1"
|
||||
DEFAULT_FORGE_API = "https://forgejo.coulomb.social/api/v1"
|
||||
DEFAULT_FORGE_OWNER = "coulomb"
|
||||
WORKPLAN_DIRS = ("workplans", "workplans/archived")
|
||||
|
||||
_SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
_REPO = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$")
|
||||
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
|
||||
_SHORT_TASK_RE = re.compile(r"^T\d{2,3}$", re.IGNORECASE)
|
||||
_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_EMPTY_FLAVOR = {"", "~", "null", "none", "nil"}
|
||||
|
||||
StatusResolver = Callable[[str], "str | None"]
|
||||
|
||||
|
||||
class EligibilityError(ValueError):
|
||||
"""Authoritative eligibility could not be established; refuse planning."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Forge reads pinned to one commit
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class ForgeCommitReader:
|
||||
"""Read-only Forgejo API access pinned to a single immutable commit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.Client,
|
||||
repo: str,
|
||||
*,
|
||||
owner: str = DEFAULT_FORGE_OWNER,
|
||||
api_base: str = DEFAULT_FORGE_API,
|
||||
) -> None:
|
||||
if not _REPO.fullmatch(repo):
|
||||
raise EligibilityError("invalid repository identity")
|
||||
self._client = client
|
||||
self._repo_url = f"{api_base.rstrip('/')}/repos/{quote(owner)}/{quote(repo)}"
|
||||
|
||||
def _get(self, path: str, **params: str) -> httpx.Response:
|
||||
try:
|
||||
response = self._client.get(f"{self._repo_url}{path}", params=params or None)
|
||||
except httpx.HTTPError as exc:
|
||||
raise EligibilityError(f"forge read failed: {type(exc).__name__}") from exc
|
||||
return response
|
||||
|
||||
def _json(self, path: str, **params: str) -> Any:
|
||||
response = self._get(path, **params)
|
||||
if response.status_code != 200:
|
||||
raise EligibilityError(f"forge read {path} returned HTTP {response.status_code}")
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise EligibilityError(f"forge read {path} returned invalid JSON") from exc
|
||||
|
||||
def verify_commit(self, sha: str) -> None:
|
||||
body = self._json(f"/git/commits/{sha}")
|
||||
if not isinstance(body, Mapping) or body.get("sha") != sha:
|
||||
raise EligibilityError("forge commit does not match scan revision")
|
||||
|
||||
def default_branch_head(self) -> str:
|
||||
repo = self._json("")
|
||||
branch = repo.get("default_branch") if isinstance(repo, Mapping) else None
|
||||
if not isinstance(branch, str) or not branch:
|
||||
raise EligibilityError("forge repository has no default branch")
|
||||
body = self._json(f"/branches/{quote(branch, safe='')}")
|
||||
commit = body.get("commit") if isinstance(body, Mapping) else None
|
||||
head = commit.get("id") if isinstance(commit, Mapping) else None
|
||||
if not isinstance(head, str) or not _SHA.fullmatch(head):
|
||||
raise EligibilityError("forge default-branch head is not a commit SHA")
|
||||
return head
|
||||
|
||||
def list_markdown(self, directory: str, sha: str) -> list[str] | None:
|
||||
"""Return ``.md`` file paths in ``directory`` at ``sha``; None if absent."""
|
||||
response = self._get(f"/contents/{quote(directory)}", ref=sha)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
raise EligibilityError(f"forge listing {directory} returned HTTP {response.status_code}")
|
||||
entries = response.json()
|
||||
if not isinstance(entries, list):
|
||||
raise EligibilityError(f"forge listing {directory} is not a directory")
|
||||
paths = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
raise EligibilityError("malformed forge directory entry")
|
||||
if entry.get("type") == "file" and str(entry.get("name", "")).endswith(".md"):
|
||||
paths.append(str(entry["path"]))
|
||||
return sorted(paths)
|
||||
|
||||
def read_text(self, path: str, sha: str) -> str:
|
||||
response = self._get(f"/raw/{quote(path)}", ref=sha)
|
||||
if response.status_code != 200:
|
||||
raise EligibilityError(f"forge raw {path} returned HTTP {response.status_code}")
|
||||
return response.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Source parsing (mirrors State Hub forge_projection semantics, strictly)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Task:
|
||||
record_id: str
|
||||
raw_id: str
|
||||
workplan_id: str
|
||||
block: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Workplan:
|
||||
record_id: str
|
||||
path: str
|
||||
meta: dict[str, Any]
|
||||
tasks: list[_Task] = field(default_factory=list)
|
||||
|
||||
|
||||
def _split_frontmatter(text: str, path: str) -> tuple[dict[str, Any], str]:
|
||||
if not text.startswith("---"):
|
||||
raise EligibilityError(f"{path}: missing frontmatter")
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
raise EligibilityError(f"{path}: unterminated frontmatter")
|
||||
try:
|
||||
meta = yaml.safe_load(text[3:end]) or {}
|
||||
except (yaml.YAMLError, ValueError) as exc:
|
||||
raise EligibilityError(f"{path}: malformed frontmatter") from exc
|
||||
if not isinstance(meta, dict):
|
||||
raise EligibilityError(f"{path}: frontmatter is not a mapping")
|
||||
return meta, text[end + 4 :]
|
||||
|
||||
|
||||
def _qualify(task_id: str, workplan_id: str) -> str:
|
||||
return f"{workplan_id}-{task_id.upper()}" if _SHORT_TASK_RE.match(task_id) else task_id
|
||||
|
||||
|
||||
def _dependency_list(value: Any, where: str) -> list[str]:
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
items: Sequence[Any] = value.split(",")
|
||||
elif isinstance(value, list):
|
||||
if any(isinstance(item, (dict, list)) for item in value):
|
||||
raise EligibilityError(f"{where}: malformed depends_on")
|
||||
items = [item for item in value if item is not None]
|
||||
else:
|
||||
raise EligibilityError(f"{where}: malformed depends_on")
|
||||
out: list[str] = []
|
||||
for item in items:
|
||||
target = str(item).strip().strip('"').strip("'").strip()
|
||||
if target and target not in out:
|
||||
out.append(target)
|
||||
return out
|
||||
|
||||
|
||||
def _workplan_dependencies(meta: Mapping[str, Any], workplan_id: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
for key in ("depends_on", "depends_on_workplans", "depends_on_tasks"):
|
||||
for target in _dependency_list(meta.get(key), f"{workplan_id} {key}"):
|
||||
target = _qualify(target, workplan_id)
|
||||
if target not in out:
|
||||
out.append(target)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_workplan(path: str, text: str) -> _Workplan:
|
||||
meta, body = _split_frontmatter(text, path)
|
||||
record_id = str(meta.get("id") or "").strip()
|
||||
if not record_id:
|
||||
raise EligibilityError(f"{path}: workplan has no id")
|
||||
workplan = _Workplan(record_id=record_id, path=path, meta=meta)
|
||||
for match in _TASK_BLOCK_RE.finditer(body):
|
||||
try:
|
||||
block = yaml.safe_load(match.group(1).strip()) or {}
|
||||
except (yaml.YAMLError, ValueError) as exc:
|
||||
raise EligibilityError(f"{path}: malformed task block") from exc
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
raw_id = str(block.get("id") or "").strip()
|
||||
if not raw_id:
|
||||
continue
|
||||
workplan.tasks.append(
|
||||
_Task(_qualify(raw_id, record_id), raw_id, record_id, block)
|
||||
)
|
||||
return workplan
|
||||
|
||||
|
||||
def _status(value: Any, where: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise EligibilityError(f"{where}: missing authoritative status")
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def _needs_human(value: Any, where: str) -> bool:
|
||||
if value is None:
|
||||
return False # same default as the forge projection
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = str(value).strip().lower()
|
||||
if text in {"true", "yes", "1", "on"}:
|
||||
return True
|
||||
if text in {"false", "no", "0", "off", ""}:
|
||||
return False
|
||||
raise EligibilityError(f"{where}: needs_human must be boolean")
|
||||
|
||||
|
||||
def _optional_text(value: Any, where: str, name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise EligibilityError(f"{where}: {name} must be text")
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
def _wait_until(value: Any, where: str) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if type(value) is date:
|
||||
return value.isoformat()
|
||||
text = str(value).strip()
|
||||
if _DATE.fullmatch(text):
|
||||
try:
|
||||
return date.fromisoformat(text).isoformat()
|
||||
except ValueError:
|
||||
pass
|
||||
raise EligibilityError(f"{where}: wait_until must be YYYY-MM-DD")
|
||||
|
||||
|
||||
def _flavor(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().lower()
|
||||
return None if text in _EMPTY_FLAVOR else text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Builder
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _load_revision(reader: ForgeCommitReader, sha: str) -> list[_Workplan]:
|
||||
paths: list[str] = []
|
||||
for directory in WORKPLAN_DIRS:
|
||||
listed = reader.list_markdown(directory, sha)
|
||||
if listed is None:
|
||||
if directory == WORKPLAN_DIRS[0]:
|
||||
raise EligibilityError("no workplans directory at scan revision")
|
||||
continue
|
||||
paths.extend(listed)
|
||||
return [_parse_workplan(path, reader.read_text(path, sha)) for path in paths]
|
||||
|
||||
|
||||
def build_current_tasks(
|
||||
*,
|
||||
target_repo: str,
|
||||
scan_revision: str,
|
||||
task_record_ids: Sequence[str],
|
||||
client: httpx.Client,
|
||||
decision_status: StatusResolver,
|
||||
external_status: StatusResolver | None = None,
|
||||
require_current_head: bool = True,
|
||||
report: Mapping[str, Any] | None = None,
|
||||
forge_owner: str = DEFAULT_FORGE_OWNER,
|
||||
forge_api: str = DEFAULT_FORGE_API,
|
||||
) -> dict[str, Any]:
|
||||
"""Return ``{"schema", "target_repo", "source_revision", "current_tasks"}``.
|
||||
|
||||
``current_tasks`` is keyed by the task record IDs requested (as the C-25
|
||||
findings name them) and is the exact ``current_tasks`` argument for
|
||||
``plan_owner_returns``. Raises :class:`EligibilityError` instead of
|
||||
returning a partial or unbound mapping.
|
||||
"""
|
||||
if not isinstance(target_repo, str) or not _REPO.fullmatch(target_repo):
|
||||
raise EligibilityError("invalid repository identity")
|
||||
if not isinstance(scan_revision, str) or not _SHA.fullmatch(scan_revision):
|
||||
raise EligibilityError("scan_revision must be a full 40-hex commit SHA")
|
||||
if report is not None:
|
||||
if report.get("repo_slug") != target_repo:
|
||||
raise EligibilityError("C-25 report is for a different repository")
|
||||
for key in ("source_revision", "commit"):
|
||||
if key in report and report[key] != scan_revision:
|
||||
raise EligibilityError("C-25 report revision does not match scan_revision")
|
||||
requested = list(dict.fromkeys(task_record_ids))
|
||||
if not requested or any(not isinstance(t, str) or not t for t in requested):
|
||||
raise EligibilityError("task_record_ids must be non-empty identities")
|
||||
|
||||
reader = ForgeCommitReader(client, target_repo, owner=forge_owner, api_base=forge_api)
|
||||
reader.verify_commit(scan_revision)
|
||||
if require_current_head and reader.default_branch_head() != scan_revision:
|
||||
raise EligibilityError("scan revision is no longer the default-branch head")
|
||||
|
||||
workplans = _load_revision(reader, scan_revision)
|
||||
by_workplan: dict[str, _Workplan] = {}
|
||||
by_task: dict[str, _Task] = {}
|
||||
by_raw: dict[str, list[_Task]] = {}
|
||||
for workplan in workplans:
|
||||
if workplan.record_id in by_workplan:
|
||||
raise EligibilityError(f"duplicate workplan identity {workplan.record_id}")
|
||||
by_workplan[workplan.record_id] = workplan
|
||||
for task in workplan.tasks:
|
||||
if task.record_id in by_task:
|
||||
raise EligibilityError(f"duplicate task identity {task.record_id}")
|
||||
by_task[task.record_id] = task
|
||||
by_raw.setdefault(task.raw_id, []).append(task)
|
||||
|
||||
def predecessor_status(target: str) -> str | None:
|
||||
if target in by_task:
|
||||
return _status(by_task[target].block.get("status"), target)
|
||||
if target in by_workplan:
|
||||
return _status(by_workplan[target].meta.get("status"), target)
|
||||
if external_status is None:
|
||||
return None
|
||||
try:
|
||||
value = external_status(target)
|
||||
except Exception: # noqa: BLE001 - unknown, never guessed
|
||||
return None
|
||||
return value.strip().lower() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
rows: dict[str, Any] = {}
|
||||
for key in requested:
|
||||
matches = [by_task[key]] if key in by_task else by_raw.get(key, [])
|
||||
if len(matches) != 1:
|
||||
raise EligibilityError(
|
||||
f"task {key} is {'ambiguous' if matches else 'absent'} at scan revision"
|
||||
)
|
||||
task = matches[0]
|
||||
workplan = by_workplan[task.workplan_id]
|
||||
where = f"{task.record_id}"
|
||||
block = task.block
|
||||
depends_on = _workplan_dependencies(workplan.meta, workplan.record_id)
|
||||
for target in _dependency_list(block.get("depends_on"), f"{where} depends_on"):
|
||||
target = _qualify(target, workplan.record_id)
|
||||
if target not in depends_on:
|
||||
depends_on.append(target)
|
||||
decision_id = _optional_text(block.get("decision_id"), where, "decision_id")
|
||||
resolved_decision: str | None = None
|
||||
if decision_id is not None:
|
||||
try:
|
||||
value = decision_status(decision_id)
|
||||
except Exception: # noqa: BLE001 - unknown stays unknown
|
||||
value = None
|
||||
resolved_decision = value if isinstance(value, str) and value else None
|
||||
rows[key] = {
|
||||
"target_repo": target_repo,
|
||||
"task_record_id": key,
|
||||
"status": _status(block.get("status"), where),
|
||||
"workplan_status": _status(workplan.meta.get("status"), workplan.record_id),
|
||||
"workplan_flavor": _flavor(workplan.meta.get("flavor")),
|
||||
"needs_human": _needs_human(block.get("needs_human"), where),
|
||||
"blocked_on": _optional_text(block.get("blocked_on"), where, "blocked_on"),
|
||||
"depends_on": depends_on,
|
||||
"predecessor_statuses": {t: predecessor_status(t) for t in depends_on},
|
||||
"wait_until": _wait_until(block.get("wait_until"), where),
|
||||
"decision_id": decision_id,
|
||||
"decision_status": resolved_decision,
|
||||
}
|
||||
return {
|
||||
"schema": SCHEMA,
|
||||
"target_repo": target_repo,
|
||||
"source_revision": scan_revision,
|
||||
"current_tasks": rows,
|
||||
}
|
||||
|
||||
|
||||
def state_hub_decision_status(client: httpx.Client, api_base: str) -> StatusResolver:
|
||||
"""Resolver reading ``GET {api_base}/decisions/{id}``; failure -> unknown."""
|
||||
|
||||
def resolve(decision_id: str) -> str | None:
|
||||
try:
|
||||
response = client.get(f"{api_base.rstrip('/')}/decisions/{quote(decision_id, safe='')}")
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
status = response.json().get("status")
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
return status if isinstance(status, str) and status else None
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def forge_head_status_resolver(
|
||||
client: httpx.Client,
|
||||
repo_for_prefix: Mapping[str, str],
|
||||
*,
|
||||
forge_owner: str = DEFAULT_FORGE_OWNER,
|
||||
forge_api: str = DEFAULT_FORGE_API,
|
||||
) -> StatusResolver:
|
||||
"""Cross-repo predecessor resolver over explicit ID-prefix -> repo mapping.
|
||||
|
||||
Each foreign repository is read once, pinned to its default-branch HEAD
|
||||
commit at first use. Unmapped prefixes and any read failure -> unknown.
|
||||
"""
|
||||
cache: dict[str, dict[str, str] | None] = {}
|
||||
|
||||
def load(repo: str) -> dict[str, str] | None:
|
||||
if repo not in cache:
|
||||
try:
|
||||
reader = ForgeCommitReader(client, repo, owner=forge_owner, api_base=forge_api)
|
||||
head = reader.default_branch_head()
|
||||
statuses: dict[str, str] = {}
|
||||
for workplan in _load_revision(reader, head):
|
||||
statuses[workplan.record_id] = _status(workplan.meta.get("status"), workplan.record_id)
|
||||
for task in workplan.tasks:
|
||||
statuses[task.record_id] = _status(task.block.get("status"), task.record_id)
|
||||
cache[repo] = statuses
|
||||
except EligibilityError:
|
||||
cache[repo] = None
|
||||
return cache[repo]
|
||||
|
||||
def resolve(target: str) -> str | None:
|
||||
prefix = next(
|
||||
(p for p in sorted(repo_for_prefix, key=len, reverse=True) if target.startswith(p)),
|
||||
None,
|
||||
)
|
||||
if prefix is None:
|
||||
return None
|
||||
statuses = load(repo_for_prefix[prefix])
|
||||
return None if statuses is None else statuses.get(target)
|
||||
|
||||
return resolve
|
||||
Loading…
Add table
Add a link
Reference in a new issue