Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67cd2da561 | ||
|
|
e9f6d68bd7 | ||
|
|
9301739910 | ||
|
|
b3859f6dad | ||
|
|
3b2b4e1dca | ||
|
|
b9ba43a5bf | ||
|
|
a1e5a4af8c | ||
|
|
188e83c4d6 | ||
|
|
db5ed6042b | ||
|
|
a942afe6c4 | ||
|
|
15c75d2225 | ||
|
|
2d95e0fcc6 | ||
|
|
8ba1c5b87c | ||
|
|
df58b5fb90 | ||
|
|
b70d5f3efa | ||
|
|
fa6ba8a162 | ||
|
|
a20975688d | ||
|
|
25bc2a3291 | ||
|
|
c040bd4674 | ||
|
|
f0c9ffb25e | ||
|
|
79256f9093 | ||
|
|
1c455b6ec0 | ||
|
|
c3f282ba44 | ||
|
|
5eb89f8830 | ||
|
|
a6c15afec1 | ||
|
|
64b6eb5d54 | ||
|
|
f21f81f9b5 | ||
|
|
08061b7b8a |
+176
-5
@@ -53,6 +53,8 @@ OUTCOME_CANDIDATE_SET_DRIFT = "candidate_set_drift"
|
||||
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
||||
# #776: controller-supplied pre-rank exclusion.
|
||||
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
||||
# #844: epic / child-only implementation container (pre-rank).
|
||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER = "epic_or_child_only_container"
|
||||
|
||||
# Ownership verdicts for a live claim on a candidate (#765).
|
||||
OWNERSHIP_OWN = "own"
|
||||
@@ -130,6 +132,39 @@ ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
||||
}
|
||||
|
||||
|
||||
# Body phrases that prove an issue is an implementation container, not a
|
||||
# unit of direct author work (#844). Matched case-insensitively against the
|
||||
# issue body. Title alone is never sufficient (ordinary issues may mention
|
||||
# "epic" incidentally).
|
||||
_CHILD_ONLY_BODY_MARKERS: tuple[str, ...] = (
|
||||
"implementation is delivered via child issues only",
|
||||
"implementation is delivered through child issues only",
|
||||
"implementation is delivered via child issues",
|
||||
"implementation is delivered through child issues",
|
||||
"do not implement product features in this epic",
|
||||
"do not implement product features in this epic issue itself",
|
||||
"no product feature implementation is claimed complete solely on this epic",
|
||||
"implementable child issues remain independently eligible",
|
||||
"owns the product roadmap and linkage",
|
||||
"this epic owns the product roadmap",
|
||||
"coordination container",
|
||||
"child-only container",
|
||||
"implementation is delegated to child",
|
||||
)
|
||||
|
||||
# Explicit epic / umbrella labels (structured evidence preferred over title).
|
||||
_EPIC_LABELS: frozenset[str] = frozenset(
|
||||
{
|
||||
"type:epic",
|
||||
"epic",
|
||||
"kind:epic",
|
||||
"scope:epic",
|
||||
"type:umbrella",
|
||||
"umbrella",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkCandidate:
|
||||
"""One assignable Gitea issue or PR presented to the allocator."""
|
||||
@@ -139,6 +174,7 @@ class WorkCandidate:
|
||||
state: str = "open"
|
||||
labels: tuple[str, ...] = ()
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
priority: int = 0
|
||||
head_sha: str | None = None
|
||||
# Routing signals (callers derive from Gitea / review feedback).
|
||||
@@ -158,6 +194,7 @@ class WorkCandidate:
|
||||
self.labels = tuple(
|
||||
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
|
||||
)
|
||||
self.body = str(self.body or "")
|
||||
if self.kind not in WORK_KINDS:
|
||||
raise InvalidWorkKindError(
|
||||
f"candidate kind '{self.kind}' is not assignable; only "
|
||||
@@ -171,6 +208,7 @@ class WorkCandidate:
|
||||
"state": self.state,
|
||||
"labels": list(self.labels),
|
||||
"title": self.title,
|
||||
"body": self.body,
|
||||
"priority": self.priority,
|
||||
"head_sha": self.head_sha,
|
||||
"request_changes_current_head": self.request_changes_current_head,
|
||||
@@ -184,6 +222,51 @@ class WorkCandidate:
|
||||
}
|
||||
|
||||
|
||||
def classify_epic_or_child_only_container(
|
||||
c: WorkCandidate,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Return whether *c* is an epic / child-only implementation container (#844).
|
||||
|
||||
Exclusion uses structured evidence first (labels, body scope language).
|
||||
A bare title containing the word "epic" is **not** enough — ordinary
|
||||
implementable issues may mention epics incidentally. A title that is
|
||||
explicitly prefixed ``Epic:`` only counts when the body also proves
|
||||
child-only / no-direct-implementation scope (or an epic label is present).
|
||||
|
||||
PRs are never classified as containers here (they already have a head).
|
||||
"""
|
||||
if c.kind != "issue":
|
||||
return False, None
|
||||
|
||||
labels = set(c.labels)
|
||||
epic_label = sorted(labels & _EPIC_LABELS)
|
||||
body_l = (c.body or "").lower()
|
||||
title = (c.title or "").strip()
|
||||
title_l = title.lower()
|
||||
|
||||
body_hits = [m for m in _CHILD_ONLY_BODY_MARKERS if m in body_l]
|
||||
title_epic_prefix = title_l.startswith("epic:") or title_l.startswith("epic ")
|
||||
|
||||
if epic_label:
|
||||
detail = f"label={epic_label[0]}"
|
||||
if body_hits:
|
||||
detail = f"{detail}; body_marker={body_hits[0]!r}"
|
||||
return True, detail
|
||||
|
||||
if body_hits:
|
||||
# Body proves child-only / umbrella scope. Title "Epic:" is corroborating
|
||||
# but not required — containers without the word still exclude.
|
||||
detail = f"body_marker={body_hits[0]!r}"
|
||||
if title_epic_prefix:
|
||||
detail = f"title_epic_prefix; {detail}"
|
||||
return True, detail
|
||||
|
||||
# Title-only "Epic:" without body scope evidence is insufficient (#844 AC:
|
||||
# eligibility does not rely solely on the word "Epic" in a title).
|
||||
# Similarly, incidental "epic" mid-title without markers stays eligible.
|
||||
return False, None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkipRecord:
|
||||
kind: str
|
||||
@@ -850,7 +933,8 @@ def allocate_next_work(
|
||||
ownership_defects: list[dict[str, Any]] = []
|
||||
controller_excluded: list[dict[str, Any]] = []
|
||||
|
||||
# #776 AC2: remove excluded numbers *before* ranking / selection / lease.
|
||||
# #776 AC2 + #844: remove excluded numbers *and* epic/child-only containers
|
||||
# *before* ranking / selection / lease so they never receive assignments.
|
||||
rankable: list[WorkCandidate] = []
|
||||
for c in candidates:
|
||||
if int(c.number) in exclude_set:
|
||||
@@ -929,6 +1013,23 @@ def allocate_next_work(
|
||||
},
|
||||
}
|
||||
continue
|
||||
# #844: epics / child-only containers are never direct implement targets.
|
||||
is_container, container_detail = classify_epic_or_child_only_container(c)
|
||||
if is_container:
|
||||
detail = container_detail or "epic or child-only container"
|
||||
reason = (
|
||||
f"{c.kind}#{c.number} {SKIP_EPIC_OR_CHILD_ONLY_CONTAINER}: "
|
||||
f"{detail}; implementation is delegated to child issues"
|
||||
)
|
||||
skipped.append(
|
||||
SkipRecord(
|
||||
c.kind,
|
||||
c.number,
|
||||
reason,
|
||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER,
|
||||
)
|
||||
)
|
||||
continue
|
||||
rankable.append(c)
|
||||
|
||||
ordered = sort_candidates(rankable)
|
||||
@@ -1115,6 +1216,12 @@ def allocate_next_work(
|
||||
"reasons": [
|
||||
"dry-run only (apply=false); no assignment/lease created — "
|
||||
"call again with apply=true to reserve via control-plane DB"
|
||||
+ (
|
||||
"; after apply, the required-role worker consumes via "
|
||||
"gitea_adopt_workflow_lease (#843)"
|
||||
if mode == ALLOCATION_MODE_CROSS_ROLE and expected_role != role_norm
|
||||
else ""
|
||||
)
|
||||
],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
@@ -1146,6 +1253,9 @@ def allocate_next_work(
|
||||
# Atomic reserve via #613 substrate.
|
||||
ttl = lease_ttl_seconds if lease_ttl_seconds is not None else None
|
||||
try:
|
||||
cross_role_handoff = (
|
||||
mode == ALLOCATION_MODE_CROSS_ROLE and lease_role != role_norm
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"role": lease_role,
|
||||
@@ -1157,7 +1267,8 @@ def allocate_next_work(
|
||||
"expected_head_sha": selected.head_sha,
|
||||
"allowed_actions": allowed,
|
||||
"forbidden_actions": forbidden,
|
||||
"phase": "allocated",
|
||||
# #843: mark cross-role allocations as awaiting independent consume
|
||||
"phase": "awaiting_handoff" if cross_role_handoff else "allocated",
|
||||
}
|
||||
if ttl is not None:
|
||||
kwargs["lease_ttl_seconds"] = int(ttl)
|
||||
@@ -1237,7 +1348,52 @@ def allocate_next_work(
|
||||
"lease_role": lease_role,
|
||||
"source": "control_plane_db.assign_and_lease",
|
||||
}
|
||||
return {
|
||||
consume_allocation = None
|
||||
if cross_role_handoff and result.lease_id:
|
||||
# Durable handoff marker so independent required-role workers can
|
||||
# consume without sharing the controller session (#843).
|
||||
handoff_prov = {
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
"allocating_session_id": session_id,
|
||||
"allocating_role": role_norm,
|
||||
"required_role": expected_role,
|
||||
"required_profile": selection["required_profile"],
|
||||
"required_namespace": selection["required_namespace"],
|
||||
"assignment_id": result.assignment_id,
|
||||
"lease_id": result.lease_id,
|
||||
"allocation_mode": mode,
|
||||
"adopted_by_session_id": None,
|
||||
}
|
||||
try:
|
||||
db.attach_lease_provenance(result.lease_id, handoff_prov)
|
||||
except ControlPlaneError:
|
||||
# Still return assignment evidence; consume path may be unavailable
|
||||
handoff_prov["attach_failed"] = True
|
||||
consume_allocation = {
|
||||
"tool": "gitea_adopt_workflow_lease",
|
||||
"lease_id": result.lease_id,
|
||||
"assignment_id": result.assignment_id,
|
||||
"required_role": expected_role,
|
||||
"required_profile": selection["required_profile"],
|
||||
"required_namespace": selection["required_namespace"],
|
||||
"handoff_status": "pending",
|
||||
"controller_session_required": False,
|
||||
"instructions": (
|
||||
f"From an independent {expected_role} session "
|
||||
f"({selection['required_namespace']} / "
|
||||
f"{selection['required_profile']}), call "
|
||||
f"gitea_adopt_workflow_lease(lease_id={result.lease_id!r}) "
|
||||
"to consume this controller allocation. The allocating "
|
||||
"controller process does not need to remain alive. Wrong-role "
|
||||
"and second-adoption attempts fail closed."
|
||||
),
|
||||
}
|
||||
lease_proof["cross_role_handoff"] = True
|
||||
lease_proof["handoff_status"] = "pending"
|
||||
lease_proof["consume_tool"] = "gitea_adopt_workflow_lease"
|
||||
|
||||
out = {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_ASSIGNED,
|
||||
"apply": True,
|
||||
@@ -1271,8 +1427,17 @@ def allocate_next_work(
|
||||
"lease_role": lease_role,
|
||||
"lease_proof": lease_proof,
|
||||
"selection_policy": SELECTION_POLICY,
|
||||
"cross_role_handoff": bool(cross_role_handoff),
|
||||
},
|
||||
"next_valid_command": _next_command(lease_role, selected),
|
||||
"next_valid_command": (
|
||||
(
|
||||
f"consume lease {result.lease_id} via gitea_adopt_workflow_lease "
|
||||
f"as {expected_role}, then "
|
||||
)
|
||||
+ _next_command(lease_role, selected)
|
||||
if cross_role_handoff
|
||||
else _next_command(lease_role, selected)
|
||||
),
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
@@ -1287,9 +1452,14 @@ def allocate_next_work(
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents; "
|
||||
"controller routes only under cross_role (#840)"
|
||||
"controller routes only under cross_role (#840); "
|
||||
"cross-role assignments are consumable by independent "
|
||||
"required-role workers via gitea_adopt_workflow_lease (#843)"
|
||||
),
|
||||
}
|
||||
if consume_allocation is not None:
|
||||
out["consume_allocation"] = consume_allocation
|
||||
return out
|
||||
|
||||
|
||||
def _next_command(role: str, c: WorkCandidate) -> str:
|
||||
@@ -1341,6 +1511,7 @@ def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
||||
state=str(data.get("state") or "open"),
|
||||
labels=tuple(data.get("labels") or ()),
|
||||
title=str(data.get("title") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
priority=priority,
|
||||
head_sha=data.get("head_sha"),
|
||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||
|
||||
@@ -0,0 +1,976 @@
|
||||
"""Sanctioned author issue worktree bootstrap for allocated issues (#850).
|
||||
|
||||
Bootstraps an allocated author branch, canonical worktree under ``branches/``,
|
||||
worktree registration, issue lock, and lease/assignment binding without
|
||||
caller-side Git, Bash, or helper scripts.
|
||||
|
||||
Features:
|
||||
1. Durable phase journal with read-after-write evidence for every phase.
|
||||
2. Idempotent replay handling via idempotency keys.
|
||||
3. Authoritative expected-base / concurrency-pin validation.
|
||||
4. Typed stale-pin refusal without silent rebasing or repointing.
|
||||
5. Canonical branches-root enforcement (path MUST be inside branches/).
|
||||
6. Preexisting work preservation (dirty tracked/untracked check, foreign ownership refusal).
|
||||
7. Compensating recovery limited strictly to artifacts created by this transition.
|
||||
8. Satisfiable exact_next_action for MCP scheduled workers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any, Mapping
|
||||
|
||||
import author_mutation_worktree
|
||||
import issue_lock_store
|
||||
import issue_lock_worktree
|
||||
import lease_lifecycle
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
import task_capability_map
|
||||
|
||||
BOOTSTRAP_TASKS = frozenset(
|
||||
{
|
||||
"bootstrap_author_issue_worktree",
|
||||
"gitea_bootstrap_author_issue_worktree",
|
||||
}
|
||||
)
|
||||
|
||||
PHASE_1_REQUEST_ACCEPTED = "1_request_accepted"
|
||||
PHASE_2_BRANCH_CONFIRMED = "2_branch_confirmed"
|
||||
PHASE_3_PATH_RESERVED = "3_path_reserved"
|
||||
PHASE_4_WORKTREE_CONFIRMED = "4_worktree_confirmed"
|
||||
PHASE_5_REGISTRATION_VERIFIED = "5_registration_verified"
|
||||
PHASE_6_STATE_ESTABLISHED = "6_state_established"
|
||||
PHASE_7_TRANSITION_COMPLETED = "7_transition_completed"
|
||||
PHASE_COMPENSATING_RECOVERY = "compensating_recovery"
|
||||
|
||||
JOURNAL_DIR_NAME = "bootstrap-journals"
|
||||
|
||||
|
||||
def is_author_issue_bootstrap_task(task: str | None) -> bool:
|
||||
"""True when *task* is the author issue worktree bootstrap task."""
|
||||
return (task or "").strip() in BOOTSTRAP_TASKS
|
||||
|
||||
|
||||
def get_journal_dir(override: str | None = None) -> str:
|
||||
"""Return the root directory for durable bootstrap phase journals."""
|
||||
if override:
|
||||
path = override
|
||||
elif os.environ.get("GITEA_BOOTSTRAP_JOURNAL_DIR"):
|
||||
path = os.environ["GITEA_BOOTSTRAP_JOURNAL_DIR"]
|
||||
else:
|
||||
cache_dir = os.path.expanduser("~/.cache/gitea-tools")
|
||||
path = os.path.join(cache_dir, JOURNAL_DIR_NAME)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _journal_file_path(idempotency_key: str, journal_dir: str | None = None) -> str:
|
||||
safe_key = "".join(
|
||||
c if c.isalnum() or c in ("-", "_", ".") else "_"
|
||||
for c in idempotency_key
|
||||
)
|
||||
return os.path.join(get_journal_dir(journal_dir), f"{safe_key}.json")
|
||||
|
||||
|
||||
def load_phase_journal(
|
||||
idempotency_key: str, journal_dir: str | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load a durable phase journal if it exists."""
|
||||
path = _journal_file_path(idempotency_key, journal_dir=journal_dir)
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_phase_journal(
|
||||
journal: dict[str, Any], journal_dir: str | None = None
|
||||
) -> None:
|
||||
"""Persist a durable phase journal with write-through file sync."""
|
||||
key = journal["idempotency_key"]
|
||||
path = _journal_file_path(key, journal_dir=journal_dir)
|
||||
tmp_path = f"{path}.tmp.{os.getpid()}"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(journal, f, indent=2, sort_keys=True)
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
|
||||
def derive_default_idempotency_key(
|
||||
remote: str,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
issue_number: int,
|
||||
assignment_id: str | None = None,
|
||||
lease_id: str | None = None,
|
||||
) -> str:
|
||||
parts = [
|
||||
"bootstrap",
|
||||
(remote or "prgs").strip(),
|
||||
(org or "Scaled-Tech-Consulting").strip(),
|
||||
(repo or "Gitea-Tools").strip(),
|
||||
f"issue-{issue_number}",
|
||||
]
|
||||
if assignment_id:
|
||||
parts.append(assignment_id.strip())
|
||||
if lease_id:
|
||||
parts.append(lease_id.strip())
|
||||
return ":".join(parts)
|
||||
|
||||
|
||||
def run_compensating_recovery(
|
||||
journal: dict[str, Any],
|
||||
canonical_repo_root: str,
|
||||
journal_dir: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute compensating recovery for artifacts created by this transition only."""
|
||||
artifacts = journal.get("artifacts_created") or {}
|
||||
pending = journal.get("pending_creations") or {}
|
||||
rolled_back: list[str] = []
|
||||
worktree_path = journal.get("worktree_path")
|
||||
branch_name = journal.get("branch_name")
|
||||
|
||||
# Roll back issue lock if created
|
||||
if artifacts.get("lock_created") or pending.get("lock"):
|
||||
issue_num = journal.get("issue_number")
|
||||
session_id = journal.get("owner_session")
|
||||
if issue_num and session_id:
|
||||
try:
|
||||
issue_lock_store.release_session_lock(
|
||||
issue_number=issue_num,
|
||||
session=session_id,
|
||||
lock_dir=journal_dir,
|
||||
)
|
||||
rolled_back.append(f"lock:issue-{issue_num}")
|
||||
except Exception:
|
||||
pass
|
||||
artifacts["lock_created"] = False
|
||||
|
||||
worktree_created = (
|
||||
artifacts.get("worktree_registered")
|
||||
or artifacts.get("worktree_dir_created")
|
||||
or (pending.get("worktree_path") == worktree_path and worktree_path)
|
||||
)
|
||||
|
||||
if worktree_created and worktree_path:
|
||||
if os.path.exists(worktree_path):
|
||||
# Re-verify cleanliness before destructive removal (F-4)
|
||||
porc_res = subprocess.run(
|
||||
["git", "-C", worktree_path, "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
is_dirty = porc_res.returncode == 0 and bool(porc_res.stdout.strip())
|
||||
if is_dirty:
|
||||
rolled_back.append(f"worktree_path_preserved_dirty:{worktree_path}")
|
||||
else:
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
canonical_repo_root,
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
worktree_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if os.path.exists(worktree_path):
|
||||
shutil.rmtree(worktree_path, ignore_errors=True)
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", canonical_repo_root, "worktree", "prune"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
rolled_back.append(f"worktree_path:{worktree_path}")
|
||||
else:
|
||||
rolled_back.append(f"worktree_path:{worktree_path}")
|
||||
|
||||
branch_created = (
|
||||
artifacts.get("branch_created")
|
||||
or (pending.get("branch_name") == branch_name and branch_name)
|
||||
)
|
||||
|
||||
if branch_created and branch_name:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
canonical_repo_root,
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
branch_name,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
# Check for author commits on branch before branch deletion (F-4)
|
||||
resolved_base = journal.get("resolved_base_sha") or "master"
|
||||
rev_list_res = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
canonical_repo_root,
|
||||
"rev-list",
|
||||
f"{resolved_base}..{branch_name}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
has_commits = rev_list_res.returncode == 0 and bool(rev_list_res.stdout.strip())
|
||||
if has_commits:
|
||||
rolled_back.append(f"branch_preserved_commits:{branch_name}")
|
||||
else:
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
canonical_repo_root,
|
||||
"branch",
|
||||
"-D",
|
||||
branch_name,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
rolled_back.append(f"branch:{branch_name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
recovery_info = {
|
||||
"executed": True,
|
||||
"rolled_back": rolled_back,
|
||||
"reason": journal.get("failure_reason"),
|
||||
}
|
||||
journal["compensating_recovery"] = recovery_info
|
||||
journal["current_phase"] = PHASE_COMPENSATING_RECOVERY
|
||||
save_phase_journal(journal, journal_dir=journal_dir)
|
||||
return recovery_info
|
||||
|
||||
|
||||
def assess_author_issue_bootstrap(
|
||||
*,
|
||||
workspace_path: str,
|
||||
canonical_repo_root: str,
|
||||
current_branch: str | None = None,
|
||||
head_sha: str | None = None,
|
||||
porcelain_status: str = "",
|
||||
remote_master_sha: str | None = None,
|
||||
remote_master_sha_error: str | None = None,
|
||||
task: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether author issue worktree bootstrap may proceed from control or worktree root."""
|
||||
root = os.path.realpath(canonical_repo_root or "")
|
||||
workspace = os.path.realpath(workspace_path or root or ".")
|
||||
branch = (current_branch or "").strip()
|
||||
dirty = parse_dirty_tracked_files(porcelain_status or "")
|
||||
under_branches = (
|
||||
author_mutation_worktree.is_path_under_branches(workspace, root)
|
||||
if root
|
||||
else False
|
||||
)
|
||||
|
||||
if not is_author_issue_bootstrap_task(task):
|
||||
return {
|
||||
"not_applicable": True,
|
||||
"allowed": False,
|
||||
"block": False,
|
||||
"proven": False,
|
||||
"reasons": ["task is not author_issue_bootstrap"],
|
||||
}
|
||||
|
||||
if under_branches:
|
||||
return {
|
||||
"not_applicable": False,
|
||||
"allowed": True,
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"bootstrap_path": "existing_branches_worktree",
|
||||
"reasons": [
|
||||
"workspace is already a registered worktree under branches/"
|
||||
],
|
||||
}
|
||||
|
||||
reasons: list[str] = []
|
||||
if workspace != root:
|
||||
reasons.append(
|
||||
"bootstrap requires workspace to be canonical control checkout or branches/ worktree"
|
||||
)
|
||||
if branch not in author_mutation_worktree.BASE_BRANCHES:
|
||||
reasons.append(
|
||||
f"control checkout branch '{branch}' is not an accepted base branch "
|
||||
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
|
||||
)
|
||||
if dirty:
|
||||
reasons.append(
|
||||
f"control checkout has tracked local edits: {', '.join(dirty[:5])}"
|
||||
)
|
||||
|
||||
if remote_master_sha_error:
|
||||
reasons.append(
|
||||
f"could not verify live master tip: {remote_master_sha_error}"
|
||||
)
|
||||
elif remote_master_sha and head_sha:
|
||||
h = head_sha.strip().lower()
|
||||
rm = remote_master_sha.strip().lower()
|
||||
if h != rm:
|
||||
reasons.append(
|
||||
f"control checkout HEAD ({h[:12]}) != live master tip ({rm[:12]})"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"not_applicable": False,
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"proven": False,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
return {
|
||||
"not_applicable": False,
|
||||
"allowed": True,
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"bootstrap_path": "clean_canonical_control_checkout",
|
||||
"reasons": [
|
||||
"control checkout is clean on accepted base branch matching live master"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
import fcntl
|
||||
import stat
|
||||
|
||||
|
||||
class BootstrapTransitionLock:
|
||||
"""Inter-process file lock scoped to the transition identity / idempotency key."""
|
||||
|
||||
def __init__(self, idempotency_key: str, journal_dir: str | None = None):
|
||||
safe_key = "".join(
|
||||
c if c.isalnum() or c in ("-", "_", ".") else "_"
|
||||
for c in idempotency_key
|
||||
)
|
||||
lock_dir = os.path.realpath(get_journal_dir(journal_dir))
|
||||
if not os.path.isdir(lock_dir):
|
||||
raise RuntimeError(f"Lock directory '{lock_dir}' does not exist or is not a directory")
|
||||
|
||||
raw_lock_path = os.path.abspath(os.path.join(lock_dir, f"{safe_key}.lock"))
|
||||
try:
|
||||
common = os.path.commonpath([lock_dir, os.path.dirname(raw_lock_path)])
|
||||
except Exception:
|
||||
common = None
|
||||
if common != lock_dir:
|
||||
raise RuntimeError(f"Lock path '{raw_lock_path}' escapes canonical lock directory '{lock_dir}'")
|
||||
|
||||
self.lock_path = raw_lock_path
|
||||
self.fd = None
|
||||
|
||||
def __enter__(self):
|
||||
if os.path.islink(self.lock_path):
|
||||
raise RuntimeError(f"Refusing lock acquisition: lock path '{self.lock_path}' is a symlink")
|
||||
|
||||
flags = os.O_RDWR | os.O_CREAT
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
if hasattr(os, "O_CLOEXEC"):
|
||||
flags |= os.O_CLOEXEC
|
||||
|
||||
try:
|
||||
fd = os.open(self.lock_path, flags, 0o600)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to open lock file safely '{self.lock_path}': {exc}") from exc
|
||||
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
os.close(fd)
|
||||
raise RuntimeError(f"Lock target '{self.lock_path}' is not a regular file")
|
||||
|
||||
self.fd = fd
|
||||
fcntl.flock(self.fd, fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.fd is not None:
|
||||
try:
|
||||
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.close(self.fd)
|
||||
except Exception:
|
||||
pass
|
||||
self.fd = None
|
||||
|
||||
|
||||
def bootstrap_author_issue_worktree(
|
||||
*,
|
||||
issue_number: int,
|
||||
canonical_repo_root: str,
|
||||
assignment_id: str | None = None,
|
||||
lease_id: str | None = None,
|
||||
expected_base_sha: str | None = None,
|
||||
branch_name: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
remote: str = "prgs",
|
||||
host: str | None = None,
|
||||
org: str | None = "Scaled-Tech-Consulting",
|
||||
repo: str | None = "Gitea-Tools",
|
||||
active_identity: str | None = "jcwalker3",
|
||||
active_profile: str | None = "prgs-author",
|
||||
owner_session: str | None = None,
|
||||
lock_dir: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute the sanctioned author issue worktree bootstrap transition."""
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
|
||||
session = (owner_session or "").strip()
|
||||
if not session:
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "missing_owner_session",
|
||||
"message": "Missing required owner_session parameter (fail closed). Session identifier cannot be fabricated or defaulted.",
|
||||
"exact_next_action": (
|
||||
"Pass explicit owner_session resolved from gitea_whoami or session context."
|
||||
),
|
||||
}
|
||||
|
||||
if active_identity is None or not str(active_identity).strip():
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "missing_active_identity",
|
||||
"message": "Missing required active_identity parameter (fail closed). Identity cannot be fabricated or defaulted.",
|
||||
"exact_next_action": "Pass explicit active_identity resolved from gitea_whoami.",
|
||||
}
|
||||
identity = str(active_identity).strip()
|
||||
|
||||
if active_profile is None or not str(active_profile).strip():
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "missing_active_profile",
|
||||
"message": "Missing required active_profile parameter (fail closed). Profile cannot be fabricated or defaulted.",
|
||||
"exact_next_action": "Pass explicit active_profile resolved from gitea_whoami.",
|
||||
}
|
||||
profile = str(active_profile).strip()
|
||||
|
||||
# Derive standard inputs
|
||||
expected_pattern = f"issue-{issue_number}"
|
||||
target_branch = (branch_name or "").strip()
|
||||
if not target_branch:
|
||||
target_branch = f"fix/issue-{issue_number}-native-mcp-bootstrap"
|
||||
elif expected_pattern not in target_branch:
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "invalid_branch_name",
|
||||
"message": (
|
||||
f"Branch name '{target_branch}' must contain issue pattern '{expected_pattern}'"
|
||||
),
|
||||
"exact_next_action": (
|
||||
f"Supply a branch_name containing '{expected_pattern}', e.g., 'fix/issue-{issue_number}-...'"
|
||||
),
|
||||
}
|
||||
|
||||
worktree_name = target_branch.replace("/", "-")
|
||||
target_worktree = (worktree_path or "").strip()
|
||||
if not target_worktree:
|
||||
target_worktree = os.path.join(root, "branches", worktree_name)
|
||||
target_worktree = os.path.realpath(os.path.abspath(target_worktree))
|
||||
|
||||
key = (idempotency_key or "").strip()
|
||||
if not key:
|
||||
key = derive_default_idempotency_key(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
assignment_id=assignment_id,
|
||||
lease_id=lease_id,
|
||||
)
|
||||
|
||||
# Acquire cross-process file lock scoped to the idempotency key / transition identity
|
||||
with BootstrapTransitionLock(key, journal_dir=lock_dir):
|
||||
# Idempotency check
|
||||
existing = load_phase_journal(key, journal_dir=lock_dir)
|
||||
if existing and existing.get("completed"):
|
||||
if (
|
||||
existing.get("issue_number") == issue_number
|
||||
and existing.get("branch_name") == target_branch
|
||||
and os.path.realpath(existing.get("worktree_path", ""))
|
||||
== target_worktree
|
||||
):
|
||||
return {
|
||||
"success": True,
|
||||
"replayed": True,
|
||||
"message": (
|
||||
f"Idempotent replay: worktree for issue #{issue_number} already bootstrapped at {target_worktree}"
|
||||
),
|
||||
"issue_number": issue_number,
|
||||
"branch_name": target_branch,
|
||||
"worktree_path": target_worktree,
|
||||
"base_sha": existing.get("resolved_base_sha"),
|
||||
"lease_id": existing.get("lease_id"),
|
||||
"assignment_id": existing.get("assignment_id"),
|
||||
"idempotency_key": key,
|
||||
"phase_journal": existing,
|
||||
"exact_next_action": (
|
||||
"Call gitea_whoami, then gitea_resolve_task_capability(task='work_issue') "
|
||||
"and proceed with author implementation in the bootstrapped worktree."
|
||||
),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "incompatible_idempotency_replay",
|
||||
"message": (
|
||||
f"Idempotency key '{key}' already exists with incompatible parameters "
|
||||
f"(stored: {existing.get('branch_name')}, {existing.get('worktree_path')}; "
|
||||
f"requested: {target_branch}, {target_worktree})"
|
||||
),
|
||||
"exact_next_action": (
|
||||
"Supply a unique idempotency_key or pass compatible parameters."
|
||||
),
|
||||
}
|
||||
|
||||
# Initialize or resume Phase Journal
|
||||
if existing:
|
||||
journal = existing
|
||||
artifacts = journal.setdefault("artifacts_created", {})
|
||||
artifacts.setdefault("branch_created", False)
|
||||
artifacts.setdefault("worktree_dir_created", False)
|
||||
artifacts.setdefault("worktree_registered", False)
|
||||
artifacts.setdefault("lock_created", False)
|
||||
else:
|
||||
journal = {
|
||||
"idempotency_key": key,
|
||||
"issue_number": issue_number,
|
||||
"assignment_id": assignment_id,
|
||||
"lease_id": lease_id,
|
||||
"expected_base_sha": expected_base_sha,
|
||||
"resolved_base_sha": None,
|
||||
"branch_name": target_branch,
|
||||
"worktree_path": target_worktree,
|
||||
"active_identity": identity,
|
||||
"active_profile": profile,
|
||||
"owner_session": session,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"phases": {},
|
||||
"artifacts_created": {
|
||||
"branch_created": False,
|
||||
"worktree_dir_created": False,
|
||||
"worktree_registered": False,
|
||||
"lock_created": False,
|
||||
},
|
||||
"current_phase": PHASE_1_REQUEST_ACCEPTED,
|
||||
"completed": False,
|
||||
}
|
||||
|
||||
# Fetch current live master SHA
|
||||
try:
|
||||
rev_res = subprocess.run(
|
||||
["git", "-C", root, "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
live_master_sha = rev_res.stdout.strip()
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "git_rev_parse_failed",
|
||||
"message": f"Could not determine repository HEAD: {exc}",
|
||||
"exact_next_action": "Verify repository git state and retry.",
|
||||
}
|
||||
|
||||
# Phase 1: REQUEST_ACCEPTED & Concurrency Pin Check
|
||||
if expected_base_sha:
|
||||
exp_norm = expected_base_sha.strip().lower()
|
||||
live_norm = live_master_sha.lower()
|
||||
if exp_norm != live_norm:
|
||||
journal["failure_reason"] = (
|
||||
f"stale concurrency pin: expected {exp_norm[:12]} != live {live_norm[:12]}"
|
||||
)
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "stale_concurrency_pin",
|
||||
"message": (
|
||||
f"Expected base SHA {exp_norm[:12]} does not match live master SHA {live_norm[:12]} (fail closed)."
|
||||
),
|
||||
"expected_base_sha": expected_base_sha,
|
||||
"live_master_sha": live_master_sha,
|
||||
"exact_next_action": (
|
||||
"Re-evaluate assignment against current live master SHA and retry with updated expected_base_sha."
|
||||
),
|
||||
}
|
||||
|
||||
journal["resolved_base_sha"] = live_master_sha
|
||||
journal["phases"][PHASE_1_REQUEST_ACCEPTED] = {
|
||||
"status": "completed",
|
||||
"live_master_sha": live_master_sha,
|
||||
"expected_base_sha": expected_base_sha,
|
||||
}
|
||||
journal["current_phase"] = PHASE_2_BRANCH_CONFIRMED
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": True,
|
||||
"message": f"Dry-run: validated bootstrap intent for issue #{issue_number}",
|
||||
"issue_number": issue_number,
|
||||
"branch_name": target_branch,
|
||||
"worktree_path": target_worktree,
|
||||
"base_sha": live_master_sha,
|
||||
"phase_journal": journal,
|
||||
"exact_next_action": "Run without dry_run=True to execute bootstrap.",
|
||||
}
|
||||
|
||||
# Phase 2: BRANCH_CONFIRMED
|
||||
was_branch_created_previously = journal["artifacts_created"].get("branch_created", False)
|
||||
pending_branch = (journal.get("pending_creations") or {}).get("branch_name")
|
||||
|
||||
branch_check = subprocess.run(
|
||||
["git", "-C", root, "rev-parse", "--verify", target_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if branch_check.returncode == 0:
|
||||
branch_head = branch_check.stdout.strip()
|
||||
# Verify branch head descends from base
|
||||
anc_check = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
root,
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
live_master_sha,
|
||||
branch_head,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if anc_check.returncode != 0 and branch_head.lower() != live_master_sha.lower():
|
||||
# F-8: Check if branch shares a common ancestor with live master
|
||||
mb_check = subprocess.run(
|
||||
["git", "-C", root, "merge-base", live_master_sha, branch_head],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if mb_check.returncode != 0 or not mb_check.stdout.strip():
|
||||
journal["failure_reason"] = (
|
||||
f"existing branch '{target_branch}' HEAD ({branch_head[:12]}) is incompatible with live master ({live_master_sha[:12]})"
|
||||
)
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "incompatible_existing_branch",
|
||||
"message": (
|
||||
f"Existing branch '{target_branch}' HEAD ({branch_head[:12]}) is incompatible with live master ({live_master_sha[:12]})."
|
||||
),
|
||||
"exact_next_action": (
|
||||
"Inspect or sync the existing branch with master before bootstrapping."
|
||||
),
|
||||
}
|
||||
# Preserve creation provenance monotonically across interruption and replay
|
||||
if was_branch_created_previously or pending_branch == target_branch:
|
||||
journal["artifacts_created"]["branch_created"] = True
|
||||
else:
|
||||
journal["artifacts_created"]["branch_created"] = False
|
||||
else:
|
||||
# Persist creation intent/provenance to disk BEFORE executing external mutation
|
||||
journal.setdefault("pending_creations", {})["branch_name"] = target_branch
|
||||
journal["artifacts_created"]["branch_created"] = True
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
|
||||
# Create branch
|
||||
create_res = subprocess.run(
|
||||
["git", "-C", root, "branch", target_branch, live_master_sha],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if create_res.returncode != 0:
|
||||
journal["artifacts_created"]["branch_created"] = False
|
||||
journal.get("pending_creations", {}).pop("branch_name", None)
|
||||
journal["failure_reason"] = (
|
||||
f"failed to create git branch '{target_branch}': {create_res.stderr.strip()}"
|
||||
)
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "branch_creation_failed",
|
||||
"message": f"Failed to create git branch '{target_branch}': {create_res.stderr.strip()}",
|
||||
"exact_next_action": "Verify branch availability and retry.",
|
||||
}
|
||||
|
||||
journal["phases"][PHASE_2_BRANCH_CONFIRMED] = {
|
||||
"status": "completed",
|
||||
"branch_name": target_branch,
|
||||
"created": journal["artifacts_created"]["branch_created"],
|
||||
}
|
||||
journal["current_phase"] = PHASE_3_PATH_RESERVED
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
|
||||
# Phase 3: PATH_RESERVED & Phase 4: WORKTREE_CONFIRMED
|
||||
if not author_mutation_worktree.is_path_under_branches(
|
||||
target_worktree, root
|
||||
):
|
||||
journal["failure_reason"] = (
|
||||
f"target_worktree '{target_worktree}' is outside canonical branches/ root"
|
||||
)
|
||||
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "path_outside_canonical_branches_root",
|
||||
"message": (
|
||||
f"Worktree path '{target_worktree}' is outside canonical branches/ root (fail closed)."
|
||||
),
|
||||
"exact_next_action": (
|
||||
"Provide a worktree_path inside canonical branches/ root, e.g., 'branches/issue-...'"
|
||||
),
|
||||
}
|
||||
|
||||
was_dir_created_previously = journal["artifacts_created"].get("worktree_dir_created", False)
|
||||
was_registered_previously = journal["artifacts_created"].get("worktree_registered", False)
|
||||
pending_wt = (journal.get("pending_creations") or {}).get("worktree_path")
|
||||
|
||||
dir_exists = os.path.exists(target_worktree)
|
||||
if dir_exists:
|
||||
# Check porcelain directly
|
||||
porc_res = subprocess.run(
|
||||
["git", "-C", target_worktree, "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
dirty = (
|
||||
parse_dirty_tracked_files(porc_res.stdout)
|
||||
if porc_res.returncode == 0
|
||||
else []
|
||||
)
|
||||
if dirty or (porc_res.returncode == 0 and porc_res.stdout.strip()):
|
||||
journal["failure_reason"] = (
|
||||
f"target worktree '{target_worktree}' contains dirty tracked/untracked files"
|
||||
)
|
||||
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "preexisting_dirty_worktree",
|
||||
"message": (
|
||||
f"Preexisting worktree '{target_worktree}' has dirty tracked/untracked files (fail closed)."
|
||||
),
|
||||
"exact_next_action": (
|
||||
"Clean or stash the pre-existing worktree files before bootstrapping."
|
||||
),
|
||||
}
|
||||
|
||||
# Check registered branch
|
||||
wt_state = issue_lock_worktree.read_worktree_git_state(target_worktree)
|
||||
wt_branch = (wt_state.get("current_branch") or "").strip()
|
||||
if wt_branch and wt_branch != target_branch:
|
||||
journal["failure_reason"] = (
|
||||
f"existing worktree '{target_worktree}' is on branch '{wt_branch}' != expected '{target_branch}'"
|
||||
)
|
||||
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "incompatible_existing_directory",
|
||||
"message": (
|
||||
f"Existing worktree '{target_worktree}' is registered to branch '{wt_branch}' instead of '{target_branch}'."
|
||||
),
|
||||
"exact_next_action": (
|
||||
"Inspect or remove the pre-existing worktree folder before bootstrapping."
|
||||
),
|
||||
}
|
||||
if was_dir_created_previously or pending_wt == target_worktree:
|
||||
journal["artifacts_created"]["worktree_dir_created"] = True
|
||||
journal["artifacts_created"]["worktree_registered"] = True
|
||||
else:
|
||||
journal["artifacts_created"]["worktree_dir_created"] = False
|
||||
journal["artifacts_created"]["worktree_registered"] = False
|
||||
else:
|
||||
# Persist creation intent/provenance to disk BEFORE executing external worktree add mutation
|
||||
journal.setdefault("pending_creations", {})["worktree_path"] = target_worktree
|
||||
journal["artifacts_created"]["worktree_dir_created"] = True
|
||||
journal["artifacts_created"]["worktree_registered"] = True
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
|
||||
wt_add_res = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
root,
|
||||
"worktree",
|
||||
"add",
|
||||
target_worktree,
|
||||
target_branch,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if wt_add_res.returncode != 0:
|
||||
journal["artifacts_created"]["worktree_dir_created"] = False
|
||||
journal["artifacts_created"]["worktree_registered"] = False
|
||||
journal.get("pending_creations", {}).pop("worktree_path", None)
|
||||
journal["failure_reason"] = (
|
||||
f"git worktree add failed: {wt_add_res.stderr.strip()}"
|
||||
)
|
||||
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "worktree_add_failed",
|
||||
"message": f"Failed to execute git worktree add: {wt_add_res.stderr.strip()}",
|
||||
"exact_next_action": "Verify git worktree capabilities and retry.",
|
||||
}
|
||||
|
||||
journal["phases"][PHASE_3_PATH_RESERVED] = {
|
||||
"status": "completed",
|
||||
"worktree_path": target_worktree,
|
||||
"preexisting_dir": dir_exists,
|
||||
}
|
||||
journal["current_phase"] = PHASE_4_WORKTREE_CONFIRMED
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
|
||||
# Phase 5: REGISTRATION_VERIFIED
|
||||
wt_list_res = subprocess.run(
|
||||
["git", "-C", root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
norm_target = os.path.realpath(target_worktree)
|
||||
found_registration = False
|
||||
if wt_list_res.returncode == 0:
|
||||
for block in wt_list_res.stdout.split("\n\n"):
|
||||
lines = block.strip().splitlines()
|
||||
worktree_line = next(
|
||||
(l[9:].strip() for l in lines if l.startswith("worktree ")),
|
||||
None,
|
||||
)
|
||||
if worktree_line and os.path.realpath(worktree_line) == norm_target:
|
||||
found_registration = True
|
||||
break
|
||||
|
||||
if not found_registration:
|
||||
journal["failure_reason"] = (
|
||||
f"worktree registration for '{target_worktree}' not found in git worktree list"
|
||||
)
|
||||
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "worktree_registration_verification_failed",
|
||||
"message": f"Worktree '{target_worktree}' registration verification failed.",
|
||||
"exact_next_action": "Check git worktree list integrity and retry.",
|
||||
}
|
||||
|
||||
journal["phases"][PHASE_4_WORKTREE_CONFIRMED] = {
|
||||
"status": "completed",
|
||||
"worktree_path": target_worktree,
|
||||
}
|
||||
journal["phases"][PHASE_5_REGISTRATION_VERIFIED] = {
|
||||
"status": "completed",
|
||||
"registered": True,
|
||||
}
|
||||
journal["current_phase"] = PHASE_6_STATE_ESTABLISHED
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
|
||||
# Phase 6: STATE_ESTABLISHED — Issue Lock Acquisition
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
lock_data = {
|
||||
"remote": remote,
|
||||
"org": org or "Scaled-Tech-Consulting",
|
||||
"repo": repo or "Gitea-Tools",
|
||||
"issue_number": issue_number,
|
||||
"branch": target_branch,
|
||||
"branch_name": target_branch,
|
||||
"worktree_path": target_worktree,
|
||||
"owner_session": session,
|
||||
"claimant": {
|
||||
"username": identity,
|
||||
"profile": profile,
|
||||
},
|
||||
"assignment_id": assignment_id,
|
||||
"lease_id": lease_id,
|
||||
"expected_base_sha": live_master_sha,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
journal.setdefault("pending_creations", {})["lock"] = True
|
||||
journal["artifacts_created"]["lock_created"] = True
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
|
||||
lock_res = issue_lock_store.bind_session_lock(lock_data, lock_dir=lock_dir)
|
||||
except Exception as exc:
|
||||
journal["artifacts_created"]["lock_created"] = False
|
||||
journal.get("pending_creations", {}).pop("lock", None)
|
||||
journal["failure_reason"] = f"issue lock binding failed: {exc}"
|
||||
run_compensating_recovery(journal, root, journal_dir=lock_dir)
|
||||
return {
|
||||
"success": False,
|
||||
"reason_code": "issue_lock_acquisition_failed",
|
||||
"message": f"Could not bind canonical issue lock for issue #{issue_number}: {exc}",
|
||||
"exact_next_action": "Verify lease/assignment state and retry.",
|
||||
}
|
||||
|
||||
journal["phases"][PHASE_6_STATE_ESTABLISHED] = {
|
||||
"status": "completed",
|
||||
"lock": lock_res,
|
||||
}
|
||||
journal["phases"][PHASE_7_TRANSITION_COMPLETED] = {
|
||||
"status": "completed",
|
||||
}
|
||||
journal["current_phase"] = PHASE_7_TRANSITION_COMPLETED
|
||||
journal["completed"] = True
|
||||
save_phase_journal(journal, journal_dir=lock_dir)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"replayed": False,
|
||||
"message": (
|
||||
f"Successfully bootstrapped author issue worktree for issue #{issue_number} "
|
||||
f"at branch '{target_branch}' and worktree '{target_worktree}'."
|
||||
),
|
||||
"issue_number": issue_number,
|
||||
"branch_name": target_branch,
|
||||
"worktree_path": target_worktree,
|
||||
"base_sha": live_master_sha,
|
||||
"lease_id": lease_id,
|
||||
"assignment_id": assignment_id,
|
||||
"idempotency_key": key,
|
||||
"lock_state": lock_res,
|
||||
"phase_journal": journal,
|
||||
"exact_next_action": (
|
||||
"Call gitea_whoami, then gitea_resolve_task_capability(task='work_issue') "
|
||||
"and proceed with author implementation in the bootstrapped worktree."
|
||||
),
|
||||
}
|
||||
+57
-36
@@ -40,23 +40,67 @@ def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def get_canonical_branches_root(project_root: str | None = None) -> str:
|
||||
"""Return the absolute path of the canonical branches directory for *project_root*."""
|
||||
root = os.path.realpath(project_root) if project_root else os.path.realpath(os.getcwd())
|
||||
canonical_repo_root = resolve_canonical_repo_root(root, root)
|
||||
return os.path.realpath(os.path.join(canonical_repo_root, "branches"))
|
||||
|
||||
|
||||
def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
|
||||
"""True when *path* resolves inside ``<project_root>/branches/``."""
|
||||
normalized = _normalize_path(path)
|
||||
if not normalized:
|
||||
"""True when *path* resolves inside a canonical ``branches/`` directory."""
|
||||
if not path or not str(path).strip():
|
||||
return False
|
||||
if "/branches/" in f"{normalized}/":
|
||||
return True
|
||||
if normalized.endswith("/branches"):
|
||||
return True
|
||||
if project_root:
|
||||
root = _normalize_path(os.path.realpath(project_root))
|
||||
real = _normalize_path(os.path.realpath(path))
|
||||
if real.startswith(f"{root}/"):
|
||||
rel = real[len(root) + 1 :]
|
||||
return rel == "branches" or rel.startswith("branches/")
|
||||
try:
|
||||
real_path = os.path.realpath(os.path.abspath(str(path).strip()))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
branches_root = get_canonical_branches_root(project_root or real_path)
|
||||
try:
|
||||
common = os.path.commonpath([branches_root, real_path])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if common != branches_root:
|
||||
return False
|
||||
|
||||
rel = os.path.relpath(real_path, branches_root)
|
||||
return rel != "." and not rel.startswith("..")
|
||||
|
||||
|
||||
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
|
||||
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
|
||||
p = (workspace_path or "").strip()
|
||||
if p:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", p, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common = _realpath_git_common_dir(p, res.stdout)
|
||||
if common.endswith(f"{os.sep}.git") or os.path.basename(common) == ".git":
|
||||
candidate_root = os.path.dirname(common)
|
||||
real_p = os.path.realpath(p)
|
||||
try:
|
||||
if os.path.commonpath([candidate_root, real_p]) == candidate_root:
|
||||
return candidate_root
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fallback = os.path.realpath(fallback_project_root or workspace_path or ".")
|
||||
norm = fallback.replace("\\", "/")
|
||||
if "/branches/" in norm:
|
||||
return os.path.realpath(norm.split("/branches/")[0])
|
||||
elif norm.endswith("/branches"):
|
||||
return os.path.realpath(os.path.dirname(fallback))
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_mutation_workspace(
|
||||
worktree_path: str | None,
|
||||
@@ -87,29 +131,6 @@ def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
|
||||
return os.path.realpath(os.path.join(workspace_path, raw))
|
||||
|
||||
|
||||
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
|
||||
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
|
||||
path = (workspace_path or "").strip()
|
||||
fallback = os.path.realpath(fallback_project_root)
|
||||
if not path:
|
||||
return fallback
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common = _realpath_git_common_dir(path, res.stdout)
|
||||
except Exception:
|
||||
return fallback
|
||||
if common.endswith(f"{os.sep}.git"):
|
||||
return os.path.dirname(common)
|
||||
if os.path.basename(common) == ".git":
|
||||
return os.path.dirname(common)
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_author_mutation_context(
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
|
||||
@@ -46,6 +46,17 @@ _FIELD_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def is_known_cth_type(value: str | None) -> bool:
|
||||
"""True when *value* is a declared member of the :data:`CTH_TYPES` contract.
|
||||
|
||||
``CTH_TYPES`` is the single authority for what a CTH type may be. The
|
||||
heading a comment carries is free text, so a *read* path that turns a parsed
|
||||
type into something durable — a serialized field, a routing decision — must
|
||||
check membership here rather than trust the parse or keep a list of its own.
|
||||
"""
|
||||
return (value or "").strip() in CTH_TYPES
|
||||
|
||||
|
||||
def format_cth_body(
|
||||
*,
|
||||
cth_type: str,
|
||||
@@ -60,7 +71,7 @@ def format_cth_body(
|
||||
) -> str:
|
||||
"""Render a canonical CTH comment body."""
|
||||
normalized_type = (cth_type or "").strip()
|
||||
if normalized_type not in CTH_TYPES:
|
||||
if not is_known_cth_type(normalized_type):
|
||||
raise ValueError(
|
||||
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
|
||||
)
|
||||
@@ -101,6 +112,12 @@ def parse_cth_comment(body: str) -> dict[str, Any] | None:
|
||||
fields[key] = match.group(2).strip()
|
||||
return {
|
||||
"cth_type": cth_type,
|
||||
# The heading capture is unconstrained free text, so the parse states
|
||||
# whether it satisfies the CTH_TYPES contract instead of leaving every
|
||||
# reader to decide (or forget). Parsing stays total — an unknown type is
|
||||
# still parsed and reported, never raised on — but a reader that turns
|
||||
# the type into a durable value can now tell the two apart.
|
||||
"cth_type_known": is_known_cth_type(cth_type),
|
||||
"fields": fields,
|
||||
"raw_body": text,
|
||||
}
|
||||
@@ -119,7 +136,7 @@ def assess_cth_comment(body: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
cth_type = parsed.get("cth_type") or ""
|
||||
if cth_type not in CTH_TYPES:
|
||||
if not is_known_cth_type(cth_type):
|
||||
reasons.append(
|
||||
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
|
||||
)
|
||||
|
||||
+169
-3
@@ -1637,11 +1637,13 @@ class ControlPlaneDB:
|
||||
provenance: dict[str, Any] | None = None,
|
||||
lease_ttl_seconds: int = DEFAULT_LEASE_TTL_SECONDS,
|
||||
) -> dict[str, Any]:
|
||||
"""Transfer or refresh a lease with provenance (#601).
|
||||
"""Transfer or refresh a lease with provenance (#601 / #843).
|
||||
|
||||
* Same owner + active → refresh (owner-resume).
|
||||
* Cross-role handoff pending + matching required role → atomic consume
|
||||
(even while the allocating controller session still "owns" the lease).
|
||||
* Expired/abandoned/released → create new assignment+lease with provenance.
|
||||
* Active foreign → raise ForeignLeaseError (never silent steal).
|
||||
* Active foreign (non-handoff) → raise ForeignLeaseError (never silent steal).
|
||||
"""
|
||||
now = _utc_now()
|
||||
now_s = _ts(now)
|
||||
@@ -1677,7 +1679,35 @@ class ControlPlaneDB:
|
||||
status = "expired"
|
||||
|
||||
owner = lease["session_id"]
|
||||
if status == "active" and owner != adopter_session_id:
|
||||
# Parse durable provenance for cross-role handoff consume (#843).
|
||||
lease_prov: dict[str, Any] = {}
|
||||
if "provenance_json" in lease.keys() and lease["provenance_json"]:
|
||||
try:
|
||||
loaded = json.loads(lease["provenance_json"])
|
||||
if isinstance(loaded, dict):
|
||||
lease_prov = loaded
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
lease_prov = {}
|
||||
handoff_pending = bool(lease_prov.get("cross_role_handoff")) and (
|
||||
str(lease_prov.get("handoff_status") or "pending").strip().lower()
|
||||
== "pending"
|
||||
)
|
||||
already_adopted = bool(
|
||||
(lease["adopted_by_session_id"] if "adopted_by_session_id" in lease.keys() else None)
|
||||
or lease_prov.get("adopted_by_session_id")
|
||||
)
|
||||
required_role = str(
|
||||
lease_prov.get("required_role") or lease["role"] or ""
|
||||
).strip().lower()
|
||||
adopter_role = (role or "").strip().lower()
|
||||
cross_role_consume = (
|
||||
handoff_pending
|
||||
and not already_adopted
|
||||
and status == "active"
|
||||
and owner != adopter_session_id
|
||||
)
|
||||
|
||||
if status == "active" and owner != adopter_session_id and not cross_role_consume:
|
||||
raise ForeignLeaseError(
|
||||
f"cannot adopt active foreign lease {lease_id} owned by {owner}"
|
||||
)
|
||||
@@ -1761,6 +1791,142 @@ class ControlPlaneDB:
|
||||
"reasons": ["owner-resume: refreshed lease with provenance"],
|
||||
}
|
||||
|
||||
# #843: controller→required-role handoff consume (atomic, same lease_id)
|
||||
if cross_role_consume:
|
||||
if not required_role:
|
||||
raise ControlPlaneError(
|
||||
f"cross-role handoff lease {lease_id} missing required_role"
|
||||
)
|
||||
if adopter_role != required_role:
|
||||
raise ForeignLeaseError(
|
||||
f"wrong role for cross-role handoff consume: "
|
||||
f"required={required_role} adopter={adopter_role or 'none'} "
|
||||
f"(fail closed)"
|
||||
)
|
||||
# CAS: only transfer if still owned by allocating session and unadopted
|
||||
cols = self._lease_columns(conn)
|
||||
adopted_col_null = (
|
||||
"(adopted_by_session_id IS NULL OR adopted_by_session_id = '')"
|
||||
if "adopted_by_session_id" in cols
|
||||
else "1=1"
|
||||
)
|
||||
cas = conn.execute(
|
||||
f"""
|
||||
UPDATE leases
|
||||
SET session_id = ?,
|
||||
heartbeat_at = ?,
|
||||
expires_at = ?,
|
||||
phase = ?,
|
||||
role = ?
|
||||
WHERE lease_id = ?
|
||||
AND status = 'active'
|
||||
AND session_id = ?
|
||||
AND {adopted_col_null}
|
||||
""",
|
||||
(
|
||||
adopter_session_id,
|
||||
now_s,
|
||||
expires,
|
||||
"adopted",
|
||||
required_role,
|
||||
lease_id,
|
||||
owner,
|
||||
),
|
||||
)
|
||||
if cas.rowcount != 1:
|
||||
raise ForeignLeaseError(
|
||||
f"cross-role handoff consume lost race for lease {lease_id} "
|
||||
"(already adopted or no longer pending; fail closed)"
|
||||
)
|
||||
if "adopted_from_session_id" in cols:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE leases
|
||||
SET adopted_from_session_id = ?, adopted_by_session_id = ?
|
||||
WHERE lease_id = ?
|
||||
""",
|
||||
(owner, adopter_session_id, lease_id),
|
||||
)
|
||||
if "worktree_path" in cols and worktree_path:
|
||||
conn.execute(
|
||||
"UPDATE leases SET worktree_path = ? WHERE lease_id = ?",
|
||||
(worktree_path, lease_id),
|
||||
)
|
||||
if "owner_pid" in cols and owner_pid is not None:
|
||||
conn.execute(
|
||||
"UPDATE leases SET owner_pid = ? WHERE lease_id = ?",
|
||||
(owner_pid, lease_id),
|
||||
)
|
||||
if "expected_head_sha" in cols and expected_head_sha:
|
||||
conn.execute(
|
||||
"UPDATE leases SET expected_head_sha = ? WHERE lease_id = ?",
|
||||
(expected_head_sha, lease_id),
|
||||
)
|
||||
# Merge handoff provenance + caller provenance
|
||||
merged = dict(lease_prov)
|
||||
merged.update(provenance or {})
|
||||
merged["cross_role_handoff"] = True
|
||||
merged["handoff_status"] = "adopted"
|
||||
merged["adopted_from_session_id"] = owner
|
||||
merged["adopted_by_session_id"] = adopter_session_id
|
||||
merged["required_role"] = required_role
|
||||
if "provenance_json" in cols:
|
||||
conn.execute(
|
||||
"UPDATE leases SET provenance_json = ? WHERE lease_id = ?",
|
||||
(json.dumps(merged), lease_id),
|
||||
)
|
||||
# Transfer active assignment ownership atomically
|
||||
asn_cas = conn.execute(
|
||||
"""
|
||||
UPDATE assignments
|
||||
SET session_id = ?, role = ?
|
||||
WHERE lease_id = ? AND status = 'active' AND session_id = ?
|
||||
""",
|
||||
(adopter_session_id, required_role, lease_id, owner),
|
||||
)
|
||||
if asn_cas.rowcount < 1:
|
||||
# Fail closed: assignment must move with the lease
|
||||
raise ControlPlaneError(
|
||||
f"cross-role handoff: no active assignment for lease {lease_id} "
|
||||
f"owned by {owner}"
|
||||
)
|
||||
lease2 = conn.execute(
|
||||
"SELECT * FROM leases WHERE lease_id = ?", (lease_id,)
|
||||
).fetchone()
|
||||
asn = conn.execute(
|
||||
"""
|
||||
SELECT * FROM assignments
|
||||
WHERE lease_id = ? AND status = 'active'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(lease_id,),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO events(work_item_id, event_type, message, created_at)
|
||||
VALUES (?, 'lease_adopted', ?, ?)
|
||||
""",
|
||||
(
|
||||
lease["work_item_id"],
|
||||
f"cross-role handoff: {adopter_session_id} consumed "
|
||||
f"{lease_id} from {owner} as {required_role}",
|
||||
now_s,
|
||||
),
|
||||
)
|
||||
return {
|
||||
"outcome": "adopted_cross_role_handoff",
|
||||
"lease": dict(lease2) if lease2 else dict(lease),
|
||||
"assignment": dict(asn) if asn else None,
|
||||
"reasons": [
|
||||
"cross-role handoff: independent required-role worker consumed "
|
||||
"controller allocation without abandonment"
|
||||
],
|
||||
"adopted_by_session_id": adopter_session_id,
|
||||
"adopted_from_session_id": owner,
|
||||
"required_role": required_role,
|
||||
"handoff_status": "adopted",
|
||||
}
|
||||
|
||||
# Non-active: create new lease + assignment (transfer)
|
||||
new_lease_id = f"lease-{uuid.uuid4().hex[:16]}"
|
||||
new_asn_id = f"asn-{uuid.uuid4().hex[:16]}"
|
||||
|
||||
@@ -247,7 +247,8 @@ def bootstrap_permits_control_checkout(
|
||||
"""
|
||||
if not isinstance(assessment, dict):
|
||||
return False
|
||||
if not is_create_issue_task(task):
|
||||
import author_issue_bootstrap
|
||||
if not is_create_issue_task(task) and not author_issue_bootstrap.is_author_issue_bootstrap_task(task):
|
||||
return False
|
||||
|
||||
# Positive proof: the assessment must affirmatively allow, with no
|
||||
|
||||
@@ -69,6 +69,7 @@ that gates each call, not which tools exist.
|
||||
- `gitea_audit_worktree_cleanup`
|
||||
- `gitea_authorize_reconciliation_cleanup_phase`
|
||||
- `gitea_authorize_review_correction`
|
||||
- `gitea_bootstrap_author_issue_worktree`
|
||||
- `gitea_capability_stop_terminal_report`
|
||||
- `gitea_capture_branches_worktree_snapshot`
|
||||
- `gitea_check_pr_eligibility`
|
||||
|
||||
@@ -74,6 +74,11 @@ status, onboarding checklist state, and the fail-closed error payloads (#635).
|
||||
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
|
||||
| `/leases` | Lease and collision visibility (#433) |
|
||||
| `/api/leases` | JSON lease/collision export |
|
||||
| `/sessions` | Phase 1 shell stub — session inventory (backed by #636) |
|
||||
| `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) |
|
||||
| `/timeline` | Phase 1 shell stub — workflow event timeline |
|
||||
| `/policy` | Phase 1 shell stub — capability/role policy placeholder |
|
||||
| `/insights` | Phase 1 shell stub — operational insights placeholder |
|
||||
|
||||
Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
||||
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
||||
@@ -233,6 +238,26 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
||||
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||
no tokens or MCP restart actions are exposed.
|
||||
|
||||
## Application shell — Phase 1 (#638)
|
||||
|
||||
The console shell (`webui/layout.py`) renders a grouped navigation driven by a
|
||||
single nav-config module, `webui/nav.py`. Nav groups follow the epic #631
|
||||
Phase 1 information architecture: **Health, Traffic, Runtime/Sessions,
|
||||
Projects, Inventory, Timeline, Policy** (placeholder), and **Insights**
|
||||
(placeholder). Live views and Phase 1 placeholders (`stub`) are declared in one
|
||||
place so the layout and the route table cannot drift.
|
||||
|
||||
The header carries two read-only status badges — an **environment** badge
|
||||
(`local` for loopback binds, `remote` otherwise, derived from `WEBUI_HOST`) and
|
||||
a **mode: read-only** badge — plus a **Docs** link to this document. No
|
||||
privileged action controls are present in the Phase 1 shell.
|
||||
|
||||
Not-yet-implemented surfaces (`/sessions`, `/inventory`, `/timeline`,
|
||||
`/policy`, `/insights`) resolve to graceful read-only stub pages instead of
|
||||
404s; their backing views land in later child issues of #631 (the inventory
|
||||
surfaces are backed by #636). Mutating methods on stub routes still fail closed
|
||||
with `read-only-mvp`.
|
||||
|
||||
## Deployment boundary (#435)
|
||||
|
||||
MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
|
||||
@@ -292,6 +317,108 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
||||
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||
no tokens or MCP restart actions are exposed.
|
||||
|
||||
## Workflow-event timeline (#637)
|
||||
|
||||
`GET /api/v1/timeline` is a read-only, versioned aggregation of workflow
|
||||
events from every available source into one normalised, filterable stream. It
|
||||
is the model layer for the Phase 1 timeline console view (a later child issue
|
||||
of #631); this issue ships the schema, adapters, and read API only.
|
||||
|
||||
### Schema (versioned)
|
||||
|
||||
`webui/timeline.py` declares `TIMELINE_SCHEMA_VERSION` (currently `1`) and the
|
||||
frozen `WorkflowEvent` record. Every response carries `schema_version` so a
|
||||
consumer can branch on shape. One event:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "control_plane",
|
||||
"event_type": "lease.renew",
|
||||
"event_key": "cp:1421",
|
||||
"timestamp": "2026-07-23T02:00:00Z",
|
||||
"actor": null,
|
||||
"role": null,
|
||||
"issue_number": 637,
|
||||
"pr_number": null,
|
||||
"session_id": null,
|
||||
"tool_name": null,
|
||||
"decision": null,
|
||||
"message": "lease renewed",
|
||||
"correlation_id": "issue#637",
|
||||
"evidence_refs": [],
|
||||
"sensitive": true
|
||||
}
|
||||
```
|
||||
|
||||
`event_key` is stable and unique per source (`cp:<event_id>`,
|
||||
`cth:<kind>:<number>:<comment_id>`), so pagination and dedup are deterministic.
|
||||
|
||||
### Sources and field authority
|
||||
|
||||
| Source | Adapter | Authority |
|
||||
|---|---|---|
|
||||
| Control-plane `events` ⋈ `work_items` | `adapt_cp_events` | `event_type`, `message`, `timestamp`, issue/PR scope come from the CP database, read through a `mode=ro` URI (never creates the DB or runs migrations) |
|
||||
| Gitea Canonical Thread Handoff comments | `adapt_cth_comments` | `actor`, `role` (next owner), `decision`, `evidence_refs`, `timestamp` come from the parsed CTH comment body (`canonical_thread_handoff`) |
|
||||
|
||||
Handoff comments are thread-scoped: they are only read when the request filters
|
||||
by a single `issue` or `pr`. Otherwise the handoff source reports `not run`
|
||||
with a reason — it is never rendered as empty-and-healthy. Each source degrades
|
||||
independently: an unavailable control-plane DB or a failed comment fetch is a
|
||||
`sources[]` entry with `ok:false` and a `reason`, never a dropped timeline.
|
||||
|
||||
### Query parameters
|
||||
|
||||
`issue`, `pr`, `session` (conjunctive filters); `limit` (default 50, max 500)
|
||||
and `offset` for pagination; `remote`, `org`, `repo` to override the default
|
||||
registry-project scope. Events sort ascending by
|
||||
`(timestamp, source_rank, event_key)`; missing timestamps sort last.
|
||||
|
||||
### Filter authority, and refusing what cannot be answered
|
||||
|
||||
A filter dimension is only meaningful for a source whose records carry it.
|
||||
Each source declares its own support in `_SOURCE_FILTER_SUPPORT` and reports it
|
||||
per response as `supported_filters` / `unsupported_filters`:
|
||||
|
||||
| Source | issue | pr | session |
|
||||
|---|---|---|---|
|
||||
| `control_plane` | yes | yes | **no** — the `events` table is `(event_id, work_item_id, event_type, message, created_at)` and records no session |
|
||||
| `gitea_handoff` | yes | yes | yes — a CTH comment declares its own `Session:` field |
|
||||
|
||||
`session_id` is read only from that declared CTH field. It is never inferred
|
||||
from a work item, an actor, or message text, and a value that is
|
||||
redaction-altering or bare-secret-shaped is dropped rather than emitted.
|
||||
|
||||
When **no source that ran** can carry a requested dimension, the request is
|
||||
refused rather than answered: the response is `422` with `ok:false` and a
|
||||
structured `error` naming `unsupported_filters` and the per-source reason. A
|
||||
`200` with zero events would tell an operator that no such activity exists,
|
||||
which is a stronger — and false — claim than "this cannot be answered here".
|
||||
A source that *can* answer the dimension and simply matched nothing still
|
||||
returns `200` with `ok:true` and an empty page.
|
||||
|
||||
### Redaction
|
||||
|
||||
Every free-text field (event messages, decision/proof text, roles, actors) is
|
||||
passed through the console redaction policy (`webui.console_redaction`, backed
|
||||
by `gitea_audit.redact`) before it leaves the module, failing closed to the
|
||||
placeholder. No unredacted tool arguments or secrets are ever emitted, and a
|
||||
generation error never drops raw data to a caller or a log.
|
||||
|
||||
Redaction also runs *before* any structured value is derived from free text.
|
||||
`evidence_refs` are extracted from already-redacted proof/decision text, and a
|
||||
commit reference is recognised only where the text declares one (`commit`,
|
||||
`head`, `base`, `sha`, …). An undeclared 40-character hex run has the exact
|
||||
shape of a Gitea access token, so it is never lifted out of prose into a
|
||||
structured field. Every reference is then independently revalidated against an
|
||||
allowed shape and a second redaction pass immediately before serialization;
|
||||
anything unproven is dropped and the event is flagged `sensitive`.
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
pytest tests/test_webui_timeline.py -q
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
|
||||
+228
-14
@@ -958,6 +958,32 @@ def _create_issue_bootstrap_assessment(
|
||||
"""
|
||||
import create_issue_bootstrap as _cib
|
||||
|
||||
import author_issue_bootstrap as _aib
|
||||
if _aib.is_author_issue_bootstrap_task(task):
|
||||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
||||
remote_master_sha_error: str | None = None
|
||||
try:
|
||||
remote_master_sha = root_checkout_guard.resolve_remote_master_sha(
|
||||
ctx["canonical_repo_root"]
|
||||
)
|
||||
except Exception as exc:
|
||||
remote_master_sha = None
|
||||
remote_master_sha_error = (
|
||||
f"{type(exc).__name__}: {exc}".strip() or "resolver failed"
|
||||
)
|
||||
return _aib.assess_author_issue_bootstrap(
|
||||
workspace_path=workspace,
|
||||
canonical_repo_root=ctx["canonical_repo_root"],
|
||||
current_branch=git_state.get("current_branch"),
|
||||
head_sha=git_state.get("head_sha"),
|
||||
porcelain_status=git_state.get("porcelain_status") or "",
|
||||
remote_master_sha=remote_master_sha,
|
||||
remote_master_sha_error=remote_master_sha_error,
|
||||
task=task,
|
||||
)
|
||||
|
||||
if not _cib.is_create_issue_task(task):
|
||||
return None
|
||||
|
||||
@@ -1450,7 +1476,7 @@ def verify_preflight_purity(
|
||||
dirty_files = sorted(
|
||||
_parse_porcelain_entries(_get_workspace_porcelain(workspace))
|
||||
)
|
||||
if dirty_files:
|
||||
if dirty_files and task != "commit_files":
|
||||
raise RuntimeError(
|
||||
nwb.format_namespace_workspace_binding_error(
|
||||
role_kind=role,
|
||||
@@ -9010,6 +9036,7 @@ def gitea_commit_files(
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Commit changes to multiple files in a Gitea repository in a single atomic commit.
|
||||
|
||||
@@ -9022,10 +9049,46 @@ def gitea_commit_files(
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
worktree_path: Optional worktree path for author mutation context.
|
||||
|
||||
Returns:
|
||||
dict with success status and commit/branch information.
|
||||
"""
|
||||
if worktree_path is None:
|
||||
lock_data = issue_lock_store.read_session_issue_lock() or {}
|
||||
worktree_path = lock_data.get("worktree_path")
|
||||
if not worktree_path:
|
||||
try:
|
||||
prof = get_profile()
|
||||
uname = prof.get("username") or prof.get("profile_name")
|
||||
for path in issue_lock_store.iter_lock_files():
|
||||
lk = issue_lock_store.read_lock_file(path) or {}
|
||||
claimant = lk.get("claimant") or {}
|
||||
if lk.get("remote") == remote and (claimant.get("username") == uname or lk.get("profile") == prof.get("profile_name")):
|
||||
issue_lock_store.bind_session_lock(lk, renewal_sanctioned=True)
|
||||
worktree_path = lk.get("worktree_path")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if worktree_path is None and files:
|
||||
for f in files:
|
||||
p = f.get("workspace_path") or f.get("local_path") or ""
|
||||
if p and os.path.isabs(p):
|
||||
real_p = os.path.realpath(p)
|
||||
real_root = os.path.realpath(PROJECT_ROOT)
|
||||
branches_dir = os.path.join(real_root, "branches")
|
||||
if real_p.startswith(branches_dir + os.sep):
|
||||
rel_sub = os.path.relpath(real_p, branches_dir)
|
||||
wt_folder = rel_sub.split(os.sep)[0]
|
||||
if wt_folder and wt_folder != "..":
|
||||
worktree_path = os.path.join(branches_dir, wt_folder)
|
||||
break
|
||||
|
||||
if worktree_path:
|
||||
os.environ["GITEA_AUTHOR_WORKTREE"] = worktree_path
|
||||
os.environ["GITEA_ACTIVE_WORKTREE"] = worktree_path
|
||||
|
||||
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
|
||||
"commit_files"
|
||||
)
|
||||
@@ -9038,7 +9101,7 @@ def gitea_commit_files(
|
||||
"reasons": block_reasons,
|
||||
}
|
||||
blocked = _namespace_mutation_block(
|
||||
"commit_files", commit="", branch="", remote=remote
|
||||
"commit_files", commit="", branch="", remote=remote, worktree_path=worktree_path
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
@@ -9066,7 +9129,7 @@ def gitea_commit_files(
|
||||
)
|
||||
|
||||
# #735: forward explicit org/repo into shared anti-stomp preflight.
|
||||
verify_preflight_purity(remote, task="commit_files", org=org, repo=repo)
|
||||
verify_preflight_purity(remote=remote, worktree_path=worktree_path, task="commit_files", org=org, repo=repo)
|
||||
processed_files, source_proofs = _prepare_commit_payload_files(files)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
@@ -9343,6 +9406,96 @@ def gitea_publish_unpublished_issue_branch(
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_bootstrap_author_issue_worktree(
|
||||
issue_number: int,
|
||||
assignment_id: str | None = None,
|
||||
lease_id: str | None = None,
|
||||
expected_base_sha: str | None = None,
|
||||
branch_name: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Bootstrap an allocated author issue branch and registered worktree (#850).
|
||||
|
||||
Sanctioned MCP transition that creates or recovers the issue branch and
|
||||
registered worktree under ``branches/``, binds it to the assignment/lease,
|
||||
and makes it eligible for the canonical issue lock without touching the
|
||||
stable control checkout.
|
||||
|
||||
Args:
|
||||
issue_number: Allocated issue number to bootstrap.
|
||||
assignment_id: Optional allocation assignment ID.
|
||||
lease_id: Optional workflow lease ID.
|
||||
expected_base_sha: Authoritative expected base SHA / concurrency pin.
|
||||
branch_name: Optional custom branch name (must match issue-<N> pattern).
|
||||
worktree_path: Optional custom worktree path under branches/.
|
||||
idempotency_key: Optional key for idempotent replay/resume.
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override Gitea host.
|
||||
org: Override Org.
|
||||
repo: Override Repo.
|
||||
dry_run: Report planned transition without mutating repository.
|
||||
"""
|
||||
task = "bootstrap_author_issue_worktree"
|
||||
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
|
||||
task
|
||||
)
|
||||
if not ok:
|
||||
return _author_mutation_block(block_reasons)
|
||||
|
||||
blocked = _namespace_mutation_block(task, remote=remote)
|
||||
if blocked:
|
||||
return blocked
|
||||
blocked = _profile_permission_block(
|
||||
task_capability_map.required_permission(task),
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
org_explicit=org is not None,
|
||||
repo_explicit=repo is not None,
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
task=task,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
canonical_root = _canonical_local_git_root()
|
||||
|
||||
import author_issue_bootstrap
|
||||
|
||||
return author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=issue_number,
|
||||
canonical_repo_root=canonical_root,
|
||||
assignment_id=assignment_id,
|
||||
lease_id=lease_id,
|
||||
expected_base_sha=expected_base_sha,
|
||||
branch_name=branch_name,
|
||||
worktree_path=worktree_path,
|
||||
idempotency_key=idempotency_key,
|
||||
remote=remote,
|
||||
host=h,
|
||||
org=o,
|
||||
repo=r,
|
||||
active_identity=_active_username(),
|
||||
active_profile=_active_profile_name(),
|
||||
owner_session=_current_session_id(),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
# Merge methods supported by the Gitea merge API.
|
||||
_MERGE_METHODS = ("merge", "squash", "rebase")
|
||||
|
||||
@@ -19203,6 +19356,12 @@ def gitea_resolve_task_capability(
|
||||
task: str,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
worktree_path: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict:
|
||||
"""Read-only / side-effect free: resolve capability, profile, and namespace for a task.
|
||||
|
||||
@@ -19219,13 +19378,14 @@ def gitea_resolve_task_capability(
|
||||
remote: Known remote instance name.
|
||||
host: Optional override for the Gitea host.
|
||||
"""
|
||||
task_key = task_capability_map._canonical_preflight_task(task)
|
||||
TASK_MAP = task_capability_map.TASK_CAPABILITY_MAP
|
||||
# Every fresh attempt invalidates the previous task/role stamp before any
|
||||
# fallible resolver work. Unknown/malformed tasks and unexpected failures
|
||||
# therefore remain fail-closed instead of preserving stale authority.
|
||||
_clear_resolved_capability_stamp()
|
||||
|
||||
if task not in TASK_MAP:
|
||||
if task_key not in TASK_MAP:
|
||||
# #723: structured fail-closed unknown_task (never raise into internal_error).
|
||||
profile = get_profile()
|
||||
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
|
||||
@@ -19271,8 +19431,8 @@ def gitea_resolve_task_capability(
|
||||
result["cleared_stale_denial"] = True
|
||||
return result
|
||||
|
||||
required_permission = task_capability_map.required_permission(task)
|
||||
required_role = task_capability_map.required_role(task)
|
||||
required_permission = task_capability_map.required_permission(task_key)
|
||||
required_role = task_capability_map.required_role(task_key)
|
||||
role_exclusive_tasks = task_capability_map.ROLE_EXCLUSIVE_TASKS
|
||||
|
||||
infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
|
||||
@@ -19458,7 +19618,10 @@ def gitea_resolve_task_capability(
|
||||
available_in_session = allowed_in_current_session
|
||||
runtime_stale_blocker = False
|
||||
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||
if (
|
||||
"PYTEST_CURRENT_TEST" not in os.environ
|
||||
or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ
|
||||
) and os.environ.get("GITEA_ALLOW_STALE_RUNTIME") != "1":
|
||||
runtime_reasons = _check_mcp_runtimes_diagnostics(task, matching_profiles)
|
||||
if runtime_reasons:
|
||||
restart_required = True
|
||||
@@ -19912,6 +20075,7 @@ def _allocator_candidates_from_gitea(
|
||||
state="open",
|
||||
labels=tuple(labels),
|
||||
title=title,
|
||||
body=body,
|
||||
priority=20 if "status:ready" in labels else 1,
|
||||
blocked=blocked,
|
||||
dependency_unmet=dep_unmet,
|
||||
@@ -21148,10 +21312,21 @@ def gitea_adopt_workflow_lease(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
) -> dict:
|
||||
"""Adopt a control-plane lease through the sanctioned path (#601).
|
||||
"""Adopt a control-plane lease through the sanctioned path (#601 / #843).
|
||||
|
||||
Same-owner resume refreshes provenance. Foreign active leases are refused.
|
||||
Expired leases may be reclaimed; provenance records adopted_from/by.
|
||||
Same-owner resume refreshes provenance. Foreign active leases are refused
|
||||
unless the lease is a pending controller cross-role handoff and the caller
|
||||
holds the required role (independent consume without sharing the
|
||||
controller session). Expired leases may be reclaimed; provenance records
|
||||
adopted_from/by. Terminal (abandoned/released) leases cannot be adopted.
|
||||
|
||||
#843 F1: the adopter role is derived authoritatively from the active
|
||||
authenticated profile — never from caller input. A supplied ``role`` that
|
||||
does not exactly match the profile-derived role is rejected (no silent
|
||||
accept or reinterpretation), and handoff provenance ``required_profile`` /
|
||||
``required_namespace`` restrictions are validated against the same
|
||||
authoritative caller context. Caller-supplied role/profile/namespace can
|
||||
never grant authority.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
@@ -21160,25 +21335,64 @@ def gitea_adopt_workflow_lease(
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip() or "session"
|
||||
active_role = (_profile_role_kind(profile) or "").strip().lower()
|
||||
if not active_role:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": "blocked",
|
||||
"mutation_performed": False,
|
||||
"reasons": [
|
||||
"active profile role could not be derived authoritatively; "
|
||||
"refusing lease adoption (fail closed, #843)"
|
||||
],
|
||||
"lease_id": lease_id,
|
||||
"authoritative_source": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
if role is not None and str(role).strip():
|
||||
supplied_role = str(role).strip().lower()
|
||||
if supplied_role != active_role:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": "blocked",
|
||||
"mutation_performed": False,
|
||||
"profile_role_kind": active_role,
|
||||
"supplied_role": supplied_role,
|
||||
"reasons": [
|
||||
f"caller-supplied role '{supplied_role}' does not match "
|
||||
f"the authenticated profile-derived role '{active_role}'; "
|
||||
"caller-supplied role/profile/namespace can never grant "
|
||||
"authority (fail closed, #843)"
|
||||
],
|
||||
"lease_id": lease_id,
|
||||
"authoritative_source": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
db, errs = _control_plane_db_or_error()
|
||||
if db is None:
|
||||
return {"success": False, "reasons": errs}
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip() or "session"
|
||||
active_role = _profile_role_kind(profile) or "author"
|
||||
sid = (session_id or "").strip() or (
|
||||
f"{profile_name}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
adopter_namespace = allocator_service.DEFAULT_ROLE_NAMESPACES.get(
|
||||
active_role, f"gitea-{active_role}"
|
||||
)
|
||||
try:
|
||||
return lease_lifecycle.adopt_lease(
|
||||
db,
|
||||
lease_id=lease_id,
|
||||
adopter_session_id=sid,
|
||||
role=(role or active_role).strip() or "author",
|
||||
role=active_role,
|
||||
worktree_path=worktree_path,
|
||||
expected_head_sha=expected_head_sha,
|
||||
owner_pid=os.getpid(),
|
||||
operator_authorized=bool(operator_authorized),
|
||||
adopter_profile_name=profile_name,
|
||||
adopter_namespace=adopter_namespace,
|
||||
)
|
||||
except (lease_lifecycle.LeaseLifecycleError, control_plane_db.ControlPlaneError) as exc:
|
||||
return {
|
||||
|
||||
+209
-13
@@ -39,6 +39,7 @@ SAFE_RELEASE_OWNED = "release_owned"
|
||||
SAFE_STALE_PROMPT = "stale_prompt_lease"
|
||||
SAFE_UNKNOWN = "inspect_only"
|
||||
SAFE_NO_AUTHORITY = "file_or_comment_not_authoritative"
|
||||
SAFE_CONSUME_CROSS_ROLE = "consume_cross_role_handoff"
|
||||
|
||||
LEASE_STATUS_ACTIVE = "active"
|
||||
LEASE_STATUS_RELEASED = "released"
|
||||
@@ -250,6 +251,23 @@ def decide_safe_next_action(
|
||||
"same_owner": True,
|
||||
"also_allowed": [SAFE_ABANDON_ALLOWED, SAFE_RELEASE_OWNED],
|
||||
}
|
||||
handoff = is_pending_cross_role_handoff({"lease": lease})
|
||||
if handoff:
|
||||
return {
|
||||
"safe_next_action": SAFE_CONSUME_CROSS_ROLE,
|
||||
"reasons": [
|
||||
f"controller allocation pending handoff (freshness={status}); "
|
||||
"required-role worker may consume without abandon/reassign; "
|
||||
f"required_role={handoff['required_role']}"
|
||||
],
|
||||
"block": False,
|
||||
"same_owner": False,
|
||||
"owner_session_id": owner,
|
||||
"required_role": handoff["required_role"],
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
"also_allowed": [SAFE_ABANDON_ALLOWED],
|
||||
}
|
||||
return {
|
||||
"safe_next_action": SAFE_ABANDON_ALLOWED,
|
||||
"reasons": [
|
||||
@@ -272,6 +290,24 @@ def decide_safe_next_action(
|
||||
}
|
||||
|
||||
if not same_owner and status == "active":
|
||||
# #843: pending cross-role handoff is consumable by required role
|
||||
handoff = is_pending_cross_role_handoff({"lease": lease})
|
||||
if handoff:
|
||||
return {
|
||||
"safe_next_action": SAFE_CONSUME_CROSS_ROLE,
|
||||
"reasons": [
|
||||
"controller cross-role allocation pending handoff; "
|
||||
f"required_role={handoff['required_role']}; "
|
||||
"consume via gitea_adopt_workflow_lease without "
|
||||
"abandonment or sharing the controller session"
|
||||
],
|
||||
"block": False,
|
||||
"same_owner": False,
|
||||
"owner_session_id": owner,
|
||||
"required_role": handoff["required_role"],
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
}
|
||||
return {
|
||||
"safe_next_action": SAFE_WAIT_FOREIGN,
|
||||
"reasons": [
|
||||
@@ -440,6 +476,84 @@ def list_active_leases(
|
||||
}
|
||||
|
||||
|
||||
|
||||
def parse_lease_provenance(lease_or_state: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return durable lease provenance dict (empty when absent/unparseable)."""
|
||||
if not lease_or_state:
|
||||
return {}
|
||||
if "provenance" in lease_or_state and isinstance(lease_or_state.get("provenance"), dict):
|
||||
return dict(lease_or_state["provenance"])
|
||||
raw = None
|
||||
if "provenance_json" in lease_or_state:
|
||||
raw = lease_or_state.get("provenance_json")
|
||||
elif "lease" in lease_or_state and isinstance(lease_or_state.get("lease"), Mapping):
|
||||
raw = lease_or_state["lease"].get("provenance_json")
|
||||
if not raw:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return dict(raw)
|
||||
try:
|
||||
loaded = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return dict(loaded) if isinstance(loaded, dict) else {}
|
||||
|
||||
|
||||
def is_pending_cross_role_handoff(
|
||||
state: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return handoff evidence when a controller allocation awaits consume (#843).
|
||||
|
||||
A pending handoff is identified by durable provenance written at
|
||||
cross-role apply time — not by title heuristics or session-id guessing.
|
||||
"""
|
||||
if not state:
|
||||
return None
|
||||
lease = state.get("lease") if isinstance(state.get("lease"), Mapping) else state
|
||||
if not isinstance(lease, Mapping):
|
||||
return None
|
||||
status = str(lease.get("status") or "").strip().lower()
|
||||
if status in (LEASE_STATUS_ABANDONED, LEASE_STATUS_RELEASED, LEASE_STATUS_EXPIRED):
|
||||
return None
|
||||
prov = parse_lease_provenance(state)
|
||||
if not prov and isinstance(lease, Mapping):
|
||||
prov = parse_lease_provenance(lease)
|
||||
if not prov.get("cross_role_handoff"):
|
||||
return None
|
||||
handoff_status = str(prov.get("handoff_status") or "pending").strip().lower()
|
||||
if handoff_status != "pending":
|
||||
return None
|
||||
adopted_by = (
|
||||
lease.get("adopted_by_session_id")
|
||||
or prov.get("adopted_by_session_id")
|
||||
or ""
|
||||
)
|
||||
if str(adopted_by).strip():
|
||||
return None
|
||||
required_role = str(
|
||||
prov.get("required_role") or lease.get("role") or ""
|
||||
).strip().lower()
|
||||
if not required_role:
|
||||
return None
|
||||
return {
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
"required_role": required_role,
|
||||
"allocating_session_id": str(
|
||||
prov.get("allocating_session_id") or lease.get("session_id") or ""
|
||||
),
|
||||
"allocating_role": str(prov.get("allocating_role") or "controller"),
|
||||
"lease_id": str(lease.get("lease_id") or ""),
|
||||
"assignment_id": (
|
||||
str(state["assignment"]["assignment_id"])
|
||||
if isinstance(state.get("assignment"), Mapping)
|
||||
and state["assignment"].get("assignment_id")
|
||||
else None
|
||||
),
|
||||
"provenance": prov,
|
||||
}
|
||||
|
||||
|
||||
def adopt_lease(
|
||||
db: cpd.ControlPlaneDB,
|
||||
*,
|
||||
@@ -450,8 +564,17 @@ def adopt_lease(
|
||||
expected_head_sha: str | None = None,
|
||||
owner_pid: int | None = None,
|
||||
operator_authorized: bool = False,
|
||||
adopter_profile_name: str | None = None,
|
||||
adopter_namespace: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Sanctioned adopt path with provenance; never silent foreign steal."""
|
||||
"""Sanctioned adopt path with provenance; never silent foreign steal.
|
||||
|
||||
#843 F1: for a pending cross-role handoff, ``role`` must be the
|
||||
authoritative profile-derived role supplied by the MCP boundary — never
|
||||
caller-asserted authority. When the handoff provenance declares
|
||||
``required_profile`` / ``required_namespace`` and the caller context is
|
||||
provided, both are validated exactly; a mismatch fails closed.
|
||||
"""
|
||||
state = db.get_lease_workflow_state(lease_id)
|
||||
if not state:
|
||||
raise LeaseLifecycleError(
|
||||
@@ -463,11 +586,8 @@ def adopt_lease(
|
||||
owner = str(lease.get("session_id") or "")
|
||||
same_owner = owner == str(adopter_session_id)
|
||||
|
||||
if freshness["freshness"] == "active" and not same_owner:
|
||||
raise LeaseLifecycleError(
|
||||
f"refusing to steal active foreign lease {lease_id} owned by "
|
||||
f"{owner} (fail closed)"
|
||||
)
|
||||
handoff = is_pending_cross_role_handoff(state)
|
||||
adopter_role = (role or "").strip().lower()
|
||||
|
||||
if freshness["freshness"] in ("abandoned", "released"):
|
||||
raise LeaseLifecycleError(
|
||||
@@ -475,13 +595,64 @@ def adopt_lease(
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
# Expired or stale: require abandon-style safety before ownership transfer
|
||||
# when not same owner; same owner may reclaim.
|
||||
if not same_owner and freshness["freshness"] in (
|
||||
if handoff and not same_owner:
|
||||
# Terminal statuses already rejected above. Freshness may be
|
||||
# active OR stale_dead_process (controller exited) — both are
|
||||
# consumable without abandonment when handoff is still pending.
|
||||
if freshness["freshness"] not in (
|
||||
"active",
|
||||
"stale_dead_process",
|
||||
"stale_missing_worktree",
|
||||
):
|
||||
raise LeaseLifecycleError(
|
||||
f"lease {lease_id} freshness={freshness['freshness']}; "
|
||||
"terminal or non-active allocation cannot be handoff-consumed "
|
||||
"(fail closed)"
|
||||
)
|
||||
required = handoff["required_role"]
|
||||
if adopter_role != required:
|
||||
raise LeaseLifecycleError(
|
||||
f"wrong role for cross-role handoff consume of {lease_id}: "
|
||||
f"required={required} adopter={adopter_role or 'none'} "
|
||||
"(fail closed)"
|
||||
)
|
||||
# #843 F1: provenance profile/namespace restrictions are validated
|
||||
# against the authoritative caller context when declared. Caller
|
||||
# input can never widen authority; a mismatch fails closed.
|
||||
handoff_prov = handoff.get("provenance") or {}
|
||||
required_profile = str(
|
||||
handoff_prov.get("required_profile") or ""
|
||||
).strip()
|
||||
if required_profile and adopter_profile_name is not None:
|
||||
if str(adopter_profile_name).strip() != required_profile:
|
||||
raise LeaseLifecycleError(
|
||||
f"wrong profile for cross-role handoff consume of "
|
||||
f"{lease_id}: required_profile={required_profile} "
|
||||
f"adopter_profile={adopter_profile_name} (fail closed)"
|
||||
)
|
||||
required_namespace = str(
|
||||
handoff_prov.get("required_namespace") or ""
|
||||
).strip()
|
||||
if required_namespace and adopter_namespace is not None:
|
||||
if str(adopter_namespace).strip() != required_namespace:
|
||||
raise LeaseLifecycleError(
|
||||
f"wrong namespace for cross-role handoff consume of "
|
||||
f"{lease_id}: required_namespace={required_namespace} "
|
||||
f"adopter_namespace={adopter_namespace} (fail closed)"
|
||||
)
|
||||
reason = "cross-role-handoff-consume"
|
||||
elif freshness["freshness"] == "active" and not same_owner:
|
||||
raise LeaseLifecycleError(
|
||||
f"refusing to steal active foreign lease {lease_id} owned by "
|
||||
f"{owner} (fail closed)"
|
||||
)
|
||||
elif not same_owner and freshness["freshness"] in (
|
||||
"expired",
|
||||
"stale_dead_process",
|
||||
"stale_missing_worktree",
|
||||
):
|
||||
# Expired or stale (non-handoff): require abandon-style safety before
|
||||
# ownership transfer when not same owner; same owner may reclaim.
|
||||
if not operator_authorized and freshness["freshness"] == "expired":
|
||||
# Deterministic reclaim of expired foreign lease is allowed
|
||||
# without operator flag (sanctioned expire reclaim).
|
||||
@@ -492,6 +663,9 @@ def adopt_lease(
|
||||
f"lease {lease_id} freshness={freshness['freshness']}; "
|
||||
"use abandon with proof before foreign adopt (fail closed)"
|
||||
)
|
||||
reason = "sanctioned-reclaim-adopt"
|
||||
else:
|
||||
reason = "owner-resume-adopt" if same_owner else "sanctioned-reclaim-adopt"
|
||||
|
||||
provenance = build_adopt_provenance(
|
||||
adopted_from_session_id=owner,
|
||||
@@ -504,10 +678,14 @@ def adopt_lease(
|
||||
worktree_path=worktree_path,
|
||||
expected_head_sha=expected_head_sha or lease.get("expected_head_sha"),
|
||||
prior_lease_id=lease_id,
|
||||
reason=(
|
||||
"owner-resume-adopt" if same_owner else "sanctioned-reclaim-adopt"
|
||||
),
|
||||
reason=reason,
|
||||
)
|
||||
if handoff and not same_owner:
|
||||
provenance["cross_role_handoff"] = True
|
||||
provenance["handoff_status"] = "adopted"
|
||||
provenance["required_role"] = handoff["required_role"]
|
||||
provenance["allocating_session_id"] = handoff["allocating_session_id"]
|
||||
provenance["allocating_role"] = handoff["allocating_role"]
|
||||
|
||||
result = db.adopt_lease(
|
||||
lease_id=lease_id,
|
||||
@@ -518,7 +696,7 @@ def adopt_lease(
|
||||
owner_pid=owner_pid if owner_pid is not None else os.getpid(),
|
||||
provenance=provenance,
|
||||
)
|
||||
return {
|
||||
out = {
|
||||
"success": True,
|
||||
"outcome": result.get("outcome"),
|
||||
"same_owner": same_owner,
|
||||
@@ -531,6 +709,24 @@ def adopt_lease(
|
||||
"comment_lease_only": False,
|
||||
"reasons": result.get("reasons") or [],
|
||||
}
|
||||
if handoff and not same_owner:
|
||||
out["cross_role_handoff"] = True
|
||||
out["handoff_status"] = "adopted"
|
||||
out["required_role"] = handoff["required_role"]
|
||||
out["adopted_by_session_id"] = adopter_session_id
|
||||
out["adopted_from_session_id"] = owner
|
||||
lease_row = result.get("lease") or {}
|
||||
if isinstance(lease_row, Mapping):
|
||||
out["read_after_write"] = {
|
||||
"lease_id": lease_row.get("lease_id"),
|
||||
"session_id": lease_row.get("session_id"),
|
||||
"role": lease_row.get("role"),
|
||||
"status": lease_row.get("status"),
|
||||
"adopted_by_session_id": lease_row.get("adopted_by_session_id"),
|
||||
"adopted_from_session_id": lease_row.get("adopted_from_session_id"),
|
||||
"phase": lease_row.get("phase"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def release_lease(
|
||||
|
||||
@@ -566,6 +566,10 @@ def build_pr_cleanup_entry(
|
||||
worktree_state=worktree_state,
|
||||
active_lock=active_lock,
|
||||
)
|
||||
planned = plan_cleanup_execution_order(
|
||||
remote_assessment=remote,
|
||||
local_assessment=local,
|
||||
)
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"issue_number": issue_number,
|
||||
@@ -576,9 +580,63 @@ def build_pr_cleanup_entry(
|
||||
"merged": merged,
|
||||
"remote_branch": remote,
|
||||
"local_worktree": local,
|
||||
# #851: dry-run and execute share the same lifecycle order description.
|
||||
"planned_execution_order": planned,
|
||||
}
|
||||
|
||||
|
||||
def plan_cleanup_execution_order(
|
||||
*,
|
||||
remote_assessment: dict[str, Any] | None,
|
||||
local_assessment: dict[str, Any] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Describe independent worktree-then-reassess-then-remote cleanup order (#851).
|
||||
|
||||
Remote ownership protection remains fail-closed at execute time. A worktree
|
||||
that is independently safe to remove is never skipped merely because remote
|
||||
deletion may be blocked by that same ``worktree_binding``.
|
||||
"""
|
||||
remote = remote_assessment or {}
|
||||
local = local_assessment or {}
|
||||
steps: list[dict[str, Any]] = []
|
||||
worktree_safe = bool(local.get("safe_to_remove_worktree"))
|
||||
remote_safe = bool(remote.get("safe_to_delete_remote"))
|
||||
|
||||
if worktree_safe:
|
||||
steps.append(
|
||||
{
|
||||
"action": "remove_local_worktree",
|
||||
"reason": "independently_safe_to_remove",
|
||||
"phase": 1,
|
||||
}
|
||||
)
|
||||
if remote_safe:
|
||||
if worktree_safe:
|
||||
steps.append(
|
||||
{
|
||||
"action": "reassess_branch_ownership",
|
||||
"reason": "after_worktree_removal_clear_worktree_binding",
|
||||
"phase": 2,
|
||||
}
|
||||
)
|
||||
steps.append(
|
||||
{
|
||||
"action": "delete_remote_branch",
|
||||
"reason": "only_if_independently_safe_after_reassessment",
|
||||
"phase": 3,
|
||||
}
|
||||
)
|
||||
else:
|
||||
steps.append(
|
||||
{
|
||||
"action": "delete_remote_branch",
|
||||
"reason": "safe_to_delete_and_no_independent_worktree_removal",
|
||||
"phase": 1,
|
||||
}
|
||||
)
|
||||
return steps
|
||||
|
||||
|
||||
def build_reconciliation_report(
|
||||
*,
|
||||
project_root: str,
|
||||
|
||||
@@ -73,6 +73,8 @@ AUTHOR_TASKS = frozenset({
|
||||
"claim_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"bootstrap_author_issue_worktree",
|
||||
"gitea_bootstrap_author_issue_worktree",
|
||||
"create_pr",
|
||||
"comment_pr",
|
||||
"address_pr_change_requests",
|
||||
|
||||
@@ -58,6 +58,14 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.branch.create",
|
||||
"role": "author",
|
||||
},
|
||||
"bootstrap_author_issue_worktree": {
|
||||
"permission": "gitea.branch.create",
|
||||
"role": "author",
|
||||
},
|
||||
"gitea_bootstrap_author_issue_worktree": {
|
||||
"permission": "gitea.branch.create",
|
||||
"role": "author",
|
||||
},
|
||||
"push_branch": {
|
||||
"permission": "gitea.branch.push",
|
||||
"role": "author",
|
||||
@@ -477,6 +485,8 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
# merger lease (#763).
|
||||
_PREFLIGHT_TASK_TRANSITIONS = frozenset({
|
||||
("review_pr", "acquire_reviewer_pr_lease"),
|
||||
("work_issue", "bootstrap_author_issue_worktree"),
|
||||
("bootstrap_author_issue_worktree", "lock_issue"),
|
||||
})
|
||||
|
||||
|
||||
@@ -523,6 +533,8 @@ ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
|
||||
"gitea_release_merger_pr_lease",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"bootstrap_author_issue_worktree",
|
||||
"gitea_bootstrap_author_issue_worktree",
|
||||
"publish_unpublished_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
@@ -548,6 +560,7 @@ ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||
"gitea_set_issue_labels": "set_issue_labels",
|
||||
"gitea_cleanup_terminal_pr_labels": "cleanup_terminal_pr_labels",
|
||||
"gitea_create_label": "create_label",
|
||||
"gitea_bootstrap_author_issue_worktree": "bootstrap_author_issue_worktree",
|
||||
"gitea_commit_files": "commit_files",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Allocator epic / child-only container pre-rank exclusion (#844).
|
||||
|
||||
Covers:
|
||||
* Issue #631-shaped child-only epic is excluded before ranking.
|
||||
* Implementable child issues remain eligible and can be selected.
|
||||
* Ordinary issues that merely mention "epic" in title/body are not excluded.
|
||||
* Excluded containers never receive assignments or workflow leases.
|
||||
* Structured skip reason ``epic_or_child_only_container`` is reported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from allocator_service import (
|
||||
OUTCOME_ASSIGNED,
|
||||
OUTCOME_PREVIEW,
|
||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
classify_epic_or_child_only_container,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB
|
||||
|
||||
REMOTE = "prgs"
|
||||
ORG = "Scaled-Tech-Consulting"
|
||||
REPO = "Gitea-Tools"
|
||||
|
||||
# Minimal body mirroring issue #631 authoritative scope language.
|
||||
_EPIC_631_BODY = """
|
||||
## Scope (umbrella)
|
||||
|
||||
This epic owns the **product roadmap and linkage** for the Web Console.
|
||||
Implementation is delivered via child issues only.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
* Do not implement product features in this epic issue itself.
|
||||
* No product feature implementation is claimed complete solely on this epic.
|
||||
"""
|
||||
|
||||
_CHILD_BODY = """
|
||||
## Problem
|
||||
|
||||
Operators need a workflow-event timeline model for Phase 1.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Timeline model API exists
|
||||
"""
|
||||
|
||||
|
||||
def _issue(
|
||||
number: int,
|
||||
*,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
labels: tuple[str, ...] = ("status:ready", "type:feature"),
|
||||
priority: int = 20,
|
||||
) -> WorkCandidate:
|
||||
return WorkCandidate(
|
||||
kind="issue",
|
||||
number=number,
|
||||
state="open",
|
||||
labels=labels,
|
||||
title=title or f"issue {number}",
|
||||
body=body,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
|
||||
class ClassifyEpicContainerTest(unittest.TestCase):
|
||||
def test_631_shaped_body_and_title_is_container(self) -> None:
|
||||
c = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
is_c, detail = classify_epic_or_child_only_container(c)
|
||||
self.assertTrue(is_c)
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertIn("body_marker", detail or "")
|
||||
|
||||
def test_body_markers_without_epic_title(self) -> None:
|
||||
c = _issue(
|
||||
900,
|
||||
title="Control plane roadmap tracker",
|
||||
body="Implementation is delivered via child issues only.",
|
||||
)
|
||||
is_c, _ = classify_epic_or_child_only_container(c)
|
||||
self.assertTrue(is_c)
|
||||
|
||||
def test_epic_label_alone_is_container(self) -> None:
|
||||
c = _issue(
|
||||
901,
|
||||
title="Roadmap linkage",
|
||||
body="Track children.",
|
||||
labels=("status:ready", "type:epic"),
|
||||
)
|
||||
is_c, detail = classify_epic_or_child_only_container(c)
|
||||
self.assertTrue(is_c)
|
||||
self.assertIn("type:epic", detail or "")
|
||||
|
||||
def test_title_epic_prefix_alone_not_container(self) -> None:
|
||||
"""Title-only 'Epic:' without body scope evidence stays eligible (#844)."""
|
||||
c = _issue(
|
||||
902,
|
||||
title="Epic: something mentioned only in title",
|
||||
body="Implement a concrete fix for the allocator skip list.",
|
||||
)
|
||||
is_c, detail = classify_epic_or_child_only_container(c)
|
||||
self.assertFalse(is_c)
|
||||
self.assertIsNone(detail)
|
||||
|
||||
def test_incidental_epic_word_not_container(self) -> None:
|
||||
c = _issue(
|
||||
903,
|
||||
title="Document epic handoff conventions",
|
||||
body=(
|
||||
"Update the docs so implementable issues that mention an epic "
|
||||
"remain independently executable."
|
||||
),
|
||||
)
|
||||
is_c, _ = classify_epic_or_child_only_container(c)
|
||||
self.assertFalse(is_c)
|
||||
|
||||
def test_prs_never_classified(self) -> None:
|
||||
pr = WorkCandidate(
|
||||
kind="pr",
|
||||
number=10,
|
||||
state="open",
|
||||
title="Epic: fake",
|
||||
body="Implementation is delivered via child issues only.",
|
||||
head_sha="a" * 40,
|
||||
priority=5,
|
||||
)
|
||||
is_c, _ = classify_epic_or_child_only_container(pr)
|
||||
self.assertFalse(is_c)
|
||||
|
||||
|
||||
class AllocateEpicContainerExclusionTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||
|
||||
def _alloc(self, candidates, **kwargs):
|
||||
defaults = dict(
|
||||
session_id="sess-844",
|
||||
role="author",
|
||||
remote=REMOTE,
|
||||
org=ORG,
|
||||
repo=REPO,
|
||||
profile_name="prgs-author",
|
||||
username="jcwalker3",
|
||||
claims={},
|
||||
apply=False,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return allocate_next_work(self.db, candidates=candidates, **defaults)
|
||||
|
||||
def test_631_shaped_epic_excluded_child_selected(self) -> None:
|
||||
epic = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
child = _issue(
|
||||
637,
|
||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
||||
body=_CHILD_BODY,
|
||||
)
|
||||
res = self._alloc([epic, child], apply=False)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(res["selected"]["number"], 637)
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertIn(631, skipped)
|
||||
self.assertEqual(
|
||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
||||
)
|
||||
self.assertIn(SKIP_EPIC_OR_CHILD_ONLY_CONTAINER, skipped[631]["reason"])
|
||||
|
||||
def test_container_cannot_receive_assignment_or_lease(self) -> None:
|
||||
epic = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
res = self._alloc([epic], apply=True)
|
||||
self.assertTrue(res["success"], res)
|
||||
# Only container present → no safe work; never assigned_work.
|
||||
self.assertNotEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertIsNone(res.get("assignment"))
|
||||
self.assertIsNone(res.get("selected"))
|
||||
skipped = {s["number"]: s for s in res["skipped"]}
|
||||
self.assertEqual(
|
||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
||||
)
|
||||
# No lease row for the epic.
|
||||
leases = self.db.list_active_leases(
|
||||
remote=REMOTE, org=ORG, repo=REPO
|
||||
) if hasattr(self.db, "list_active_leases") else []
|
||||
# Prefer generic inventory if available.
|
||||
if not leases and hasattr(self.db, "list_leases"):
|
||||
leases = self.db.list_leases(remote=REMOTE, org=ORG, repo=REPO)
|
||||
for lease in leases or []:
|
||||
work_number = lease.get("work_number") if isinstance(lease, dict) else None
|
||||
self.assertNotEqual(work_number, 631)
|
||||
|
||||
def test_incidental_epic_title_remains_eligible(self) -> None:
|
||||
ordinary = _issue(
|
||||
700,
|
||||
title="Document epic handoff conventions",
|
||||
body="Write runbook text about epic vs child issues.",
|
||||
)
|
||||
res = self._alloc([ordinary], apply=False)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertEqual(res["selected"]["number"], 700)
|
||||
self.assertEqual(res["skipped"], [])
|
||||
|
||||
def test_apply_selects_child_not_epic(self) -> None:
|
||||
epic = _issue(
|
||||
631,
|
||||
title="Epic: MCP Control Plane Web Console",
|
||||
body=_EPIC_631_BODY,
|
||||
)
|
||||
child = _issue(
|
||||
637,
|
||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
||||
body=_CHILD_BODY,
|
||||
)
|
||||
res = self._alloc([epic, child], apply=True)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(res["selected"]["number"], 637)
|
||||
self.assertEqual(res["assignment"]["work_number"], 637)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Regression test suite for native author issue worktree bootstrap (#850)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import author_issue_bootstrap
|
||||
import task_capability_map
|
||||
|
||||
|
||||
def _concurrent_bootstrap_worker(args: tuple[str, int, str, str, str, str]) -> dict:
|
||||
repo_dir, issue_num, key, lock_dir, journal_dir, master_sha = args
|
||||
os.environ["GITEA_BOOTSTRAP_JOURNAL_DIR"] = journal_dir
|
||||
return author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=issue_num,
|
||||
canonical_repo_root=repo_dir,
|
||||
expected_base_sha=master_sha,
|
||||
idempotency_key=key,
|
||||
lock_dir=lock_dir,
|
||||
owner_session="session-concurrent-test",
|
||||
active_identity="jcwalker3",
|
||||
active_profile="prgs-author",
|
||||
)
|
||||
|
||||
|
||||
class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
"""Test suite covering AC1-AC10 and comment #14959 specification."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp_dir = tempfile.mkdtemp(prefix="test_bootstrap_")
|
||||
self.repo_dir = os.path.join(self.tmp_dir, "repo")
|
||||
os.makedirs(self.repo_dir)
|
||||
|
||||
# Initialize synthetic git repo
|
||||
subprocess.run(["git", "init", "-b", "master"], cwd=self.repo_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.repo_dir, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=self.repo_dir, check=True)
|
||||
|
||||
readme = os.path.join(self.repo_dir, "README.md")
|
||||
with open(readme, "w", encoding="utf-8") as f:
|
||||
f.write("# Test Repo\n")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=self.repo_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "initial commit"], cwd=self.repo_dir, check=True, capture_output=True)
|
||||
|
||||
rev_res = subprocess.run(["git", "rev-parse", "HEAD"], cwd=self.repo_dir, capture_output=True, text=True, check=True)
|
||||
self.master_sha = rev_res.stdout.strip()
|
||||
|
||||
self.branches_dir = os.path.join(self.repo_dir, "branches")
|
||||
os.makedirs(self.branches_dir, exist_ok=True)
|
||||
self.lock_dir = os.path.join(self.tmp_dir, "locks")
|
||||
os.makedirs(self.lock_dir, exist_ok=True)
|
||||
self.journal_dir = os.path.join(self.tmp_dir, "journals")
|
||||
os.makedirs(self.journal_dir, exist_ok=True)
|
||||
os.environ["GITEA_BOOTSTRAP_JOURNAL_DIR"] = self.journal_dir
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("GITEA_BOOTSTRAP_JOURNAL_DIR", None)
|
||||
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
||||
|
||||
def test_bootstrap_success_path(self):
|
||||
"""AC1/AC3/AC8: Successful bootstrap creates branch, worktree, registration, and lock proof."""
|
||||
key = "test_key_success_1"
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
assignment_id="asn-12345",
|
||||
lease_id="lease-67890",
|
||||
expected_base_sha=self.master_sha,
|
||||
idempotency_key=key,
|
||||
remote="prgs",
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res.get("success"), f"Bootstrap failed: {res}")
|
||||
self.assertFalse(res.get("replayed"))
|
||||
self.assertEqual(res.get("issue_number"), 850)
|
||||
self.assertEqual(res.get("base_sha"), self.master_sha)
|
||||
self.assertIn("branches/fix-issue-850-native-mcp-bootstrap", res.get("worktree_path"))
|
||||
|
||||
# Verify worktree directory exists and is registered
|
||||
worktree_path = res["worktree_path"]
|
||||
self.assertTrue(os.path.isdir(worktree_path))
|
||||
|
||||
wt_list = subprocess.run(["git", "-C", self.repo_dir, "worktree", "list"], capture_output=True, text=True, check=True)
|
||||
self.assertIn(worktree_path, wt_list.stdout)
|
||||
|
||||
# Verify phase journal written
|
||||
journal = author_issue_bootstrap.load_phase_journal(key, journal_dir=self.lock_dir)
|
||||
self.assertIsNotNone(journal)
|
||||
self.assertTrue(journal.get("completed"))
|
||||
self.assertEqual(journal.get("current_phase"), author_issue_bootstrap.PHASE_7_TRANSITION_COMPLETED)
|
||||
|
||||
def test_idempotent_replay(self):
|
||||
"""Item 2: Replaying with identical key returns cached transition without duplicate creation."""
|
||||
key = "test_key_idempotent_1"
|
||||
res1 = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res1["success"], f"res1 failed: {res1}")
|
||||
self.assertFalse(res1.get("replayed"))
|
||||
|
||||
# Second call
|
||||
res2 = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res2["success"], f"res2 failed: {res2}")
|
||||
self.assertTrue(res2.get("replayed"))
|
||||
self.assertEqual(res1["worktree_path"], res2["worktree_path"])
|
||||
|
||||
def test_stale_concurrency_pin_refusal(self):
|
||||
"""Item 3: Mismatched expected base SHA fails closed without silent rebasing."""
|
||||
stale_sha = "0000000000000000000000000000000000000000"
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
expected_base_sha=stale_sha,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "stale_concurrency_pin")
|
||||
self.assertIn("exact_next_action", res)
|
||||
|
||||
def test_path_outside_branches_root_refusal(self):
|
||||
"""Item 6: Worktree path outside branches/ root is refused."""
|
||||
outside_path = os.path.join(self.tmp_dir, "outside_worktree")
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
worktree_path=outside_path,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "path_outside_canonical_branches_root")
|
||||
|
||||
def test_preexisting_dirty_worktree_preservation(self):
|
||||
"""Item 6: Preexisting dirty worktree fails closed and is NOT modified or cleaned."""
|
||||
branch = "fix/issue-850-dirty-test"
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-dirty-test")
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", "-b", branch, wt_path], check=True, capture_output=True)
|
||||
|
||||
# Create dirty untracked file
|
||||
dirty_file = os.path.join(wt_path, "dirty.txt")
|
||||
with open(dirty_file, "w") as f:
|
||||
f.write("dirty edits\n")
|
||||
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name=branch,
|
||||
worktree_path=wt_path,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "preexisting_dirty_worktree")
|
||||
|
||||
# Prove dirty file is preserved byte-for-byte
|
||||
self.assertTrue(os.path.exists(dirty_file))
|
||||
with open(dirty_file, "r") as f:
|
||||
self.assertEqual(f.read(), "dirty edits\n")
|
||||
|
||||
def test_compensating_recovery_on_failed_phase(self):
|
||||
"""AC4/Item 4: Failure during transition rolls back ONLY newly created artifacts."""
|
||||
key = "test_key_recovery_1"
|
||||
# Simulate partial progress in journal
|
||||
journal = {
|
||||
"idempotency_key": key,
|
||||
"issue_number": 850,
|
||||
"branch_name": "fix/issue-850-recovery-test",
|
||||
"worktree_path": os.path.join(self.branches_dir, "fix-issue-850-recovery-test"),
|
||||
"artifacts_created": {
|
||||
"branch_created": True,
|
||||
"worktree_dir_created": True,
|
||||
"worktree_registered": True,
|
||||
"lock_created": False,
|
||||
},
|
||||
"failure_reason": "simulated lock failure",
|
||||
"current_phase": author_issue_bootstrap.PHASE_5_REGISTRATION_VERIFIED,
|
||||
"completed": False,
|
||||
}
|
||||
# Create the branch and worktree manually to simulate partial state
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", journal["branch_name"]], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", journal["worktree_path"], journal["branch_name"]], check=True, capture_output=True)
|
||||
|
||||
# Run compensating recovery
|
||||
rec = author_issue_bootstrap.run_compensating_recovery(journal, self.repo_dir)
|
||||
self.assertTrue(rec["executed"])
|
||||
self.assertIn(f"worktree_path:{journal['worktree_path']}", rec["rolled_back"])
|
||||
self.assertIn(f"branch:{journal['branch_name']}", rec["rolled_back"])
|
||||
|
||||
# Prove worktree directory and branch were rolled back
|
||||
self.assertFalse(os.path.exists(journal["worktree_path"]))
|
||||
branch_check = subprocess.run(["git", "-C", self.repo_dir, "rev-parse", "--verify", journal["branch_name"]], capture_output=True, text=True, check=False)
|
||||
self.assertNotEqual(branch_check.returncode, 0)
|
||||
|
||||
def test_cross_process_concurrency(self):
|
||||
"""Review #525 Finding 1: Genuine cross-process concurrency locking prevents corruption."""
|
||||
import concurrent.futures
|
||||
|
||||
key = "test_concurrent_key_850"
|
||||
args = (self.repo_dir, 850, key, self.lock_dir, self.journal_dir, self.master_sha)
|
||||
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=2) as executor:
|
||||
fut1 = executor.submit(_concurrent_bootstrap_worker, args)
|
||||
fut2 = executor.submit(_concurrent_bootstrap_worker, args)
|
||||
res1 = fut1.result(timeout=10)
|
||||
res2 = fut2.result(timeout=10)
|
||||
|
||||
self.assertTrue(res1["success"], f"res1 failed: {res1}")
|
||||
self.assertTrue(res2["success"], f"res2 failed: {res2}")
|
||||
# One process performs creation, the other process receives idempotent replay
|
||||
replayed_count = sum(1 for r in (res1, res2) if r.get("replayed"))
|
||||
created_count = sum(1 for r in (res1, res2) if not r.get("replayed"))
|
||||
self.assertEqual(replayed_count, 1)
|
||||
self.assertEqual(created_count, 1)
|
||||
self.assertEqual(res1["worktree_path"], res2["worktree_path"])
|
||||
|
||||
def test_interrupted_replay_preserves_artifacts_created_provenance(self):
|
||||
"""Review #525 Finding 2: Replaying incomplete journal preserves creation provenance monotonically."""
|
||||
key = "test_key_interrupted_replay_1"
|
||||
branch = "fix/issue-850-interrupted-replay"
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-interrupted-replay")
|
||||
|
||||
# Simulate Phase 2/3 completion where branch and worktree directory were created by this transition
|
||||
journal = {
|
||||
"idempotency_key": key,
|
||||
"issue_number": 850,
|
||||
"branch_name": branch,
|
||||
"worktree_path": wt_path,
|
||||
"active_identity": "jcwalker3",
|
||||
"active_profile": "prgs-author",
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"phases": {
|
||||
author_issue_bootstrap.PHASE_1_REQUEST_ACCEPTED: {"status": "completed"},
|
||||
author_issue_bootstrap.PHASE_2_BRANCH_CONFIRMED: {"status": "completed", "created": True},
|
||||
},
|
||||
"artifacts_created": {
|
||||
"branch_created": True,
|
||||
"worktree_dir_created": True,
|
||||
"worktree_registered": True,
|
||||
"lock_created": False,
|
||||
},
|
||||
"current_phase": author_issue_bootstrap.PHASE_3_PATH_RESERVED,
|
||||
"completed": False,
|
||||
}
|
||||
# Pre-create the branch and worktree on disk to simulate partial state after crash
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", branch, self.master_sha], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", wt_path, branch], check=True, capture_output=True)
|
||||
author_issue_bootstrap.save_phase_journal(journal, journal_dir=self.lock_dir)
|
||||
|
||||
# Now resume/replay the transition but simulate lock binding failure during Phase 6
|
||||
with mock.patch("issue_lock_store.bind_session_lock", side_effect=RuntimeError("Lock failure test")):
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name=branch,
|
||||
worktree_path=wt_path,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "issue_lock_acquisition_failed")
|
||||
|
||||
# Verify that compensating recovery correctly deleted transition-created branch & worktree
|
||||
# because creation provenance was preserved across replay (NOT downgraded to False!)
|
||||
self.assertFalse(os.path.exists(wt_path))
|
||||
branch_check = subprocess.run(["git", "-C", self.repo_dir, "rev-parse", "--verify", branch], capture_output=True, text=True, check=False)
|
||||
self.assertNotEqual(branch_check.returncode, 0)
|
||||
|
||||
def test_transition_created_only_compensation(self):
|
||||
"""Review #525 Finding 4: Preexisting branch is NOT deleted by compensation when only worktree was transition-created."""
|
||||
key = "test_key_preexisting_branch_compensation"
|
||||
preexisting_branch = "fix/issue-850-preexisting"
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-preexisting")
|
||||
|
||||
# Create branch BEFORE bootstrap (preexisting branch)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", preexisting_branch, self.master_sha], check=True, capture_output=True)
|
||||
|
||||
# Call bootstrap with simulated failure during Phase 6 (lock binding)
|
||||
with mock.patch("issue_lock_store.bind_session_lock", side_effect=RuntimeError("Simulated lock failure")):
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name=preexisting_branch,
|
||||
worktree_path=wt_path,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
|
||||
self.assertFalse(res["success"])
|
||||
# Worktree dir was created by transition -> removed by compensation
|
||||
self.assertFalse(os.path.exists(wt_path))
|
||||
|
||||
# Preexisting branch was NOT created by transition -> MUST BE PRESERVED!
|
||||
branch_check = subprocess.run(["git", "-C", self.repo_dir, "rev-parse", "--verify", preexisting_branch], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(branch_check.returncode, 0, "Preexisting branch was deleted by mistake!")
|
||||
|
||||
def test_incompatible_idempotency_replay_refusal(self):
|
||||
"""Review #525 Finding 4: Replaying key with incompatible parameters returns refusal."""
|
||||
key = "test_key_incompatible_replay"
|
||||
res1 = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name="fix/issue-850-param-a",
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res1["success"])
|
||||
|
||||
# Second call with different branch_name
|
||||
res2 = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
branch_name="fix/issue-850-param-b",
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res2["success"])
|
||||
self.assertEqual(res2.get("reason_code"), "incompatible_idempotency_replay")
|
||||
|
||||
def test_exact_next_action_satisfiable_via_mcp(self):
|
||||
"""Review #525 Finding 4: exact_next_action provides satisfiable MCP actions, not shell commands."""
|
||||
key = "test_key_next_action_mcp"
|
||||
stale_sha = "0000000000000000000000000000000000000000"
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
expected_base_sha=stale_sha,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
next_action = res.get("exact_next_action", "")
|
||||
self.assertNotIn("scripts/worktree-start", next_action)
|
||||
self.assertNotIn("git worktree add", next_action)
|
||||
self.assertNotIn("bash", next_action.lower())
|
||||
|
||||
def test_missing_owner_session_refusal(self):
|
||||
"""Finding D: Missing owner_session context fails closed with typed refusal and zero mutation."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
owner_session=None,
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "missing_owner_session")
|
||||
self.assertIn("exact_next_action", res)
|
||||
|
||||
def test_symlink_lock_file_refusal(self):
|
||||
"""Finding C: BootstrapTransitionLock refuses to follow symlinks."""
|
||||
key = "test_symlink_lock_key"
|
||||
safe_key = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in key)
|
||||
lock_path = os.path.join(self.lock_dir, f"{safe_key}.lock")
|
||||
target_file = os.path.join(self.tmp_dir, "fake_target")
|
||||
with open(target_file, "w") as f:
|
||||
f.write("target")
|
||||
os.symlink(target_file, lock_path)
|
||||
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with author_issue_bootstrap.BootstrapTransitionLock(key, journal_dir=self.lock_dir):
|
||||
pass
|
||||
self.assertIn("symlink", str(ctx.exception).lower())
|
||||
|
||||
def test_lock_directory_escape_refusal(self):
|
||||
"""Finding C: BootstrapTransitionLock refuses keys that escape lock directory."""
|
||||
with mock.patch("os.path.abspath", return_value="/tmp/outside/evil_key.lock"):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
author_issue_bootstrap.BootstrapTransitionLock("key", journal_dir=self.lock_dir)
|
||||
self.assertIn("escapes", str(ctx.exception).lower())
|
||||
|
||||
def test_missing_active_identity_refusal(self):
|
||||
"""F-5: Missing active_identity parameter fails closed."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
owner_session="session-test-1234",
|
||||
active_identity=None,
|
||||
active_profile="prgs-author",
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "missing_active_identity")
|
||||
|
||||
def test_missing_active_profile_refusal(self):
|
||||
"""F-5: Missing active_profile parameter fails closed."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
owner_session="session-test-1234",
|
||||
active_identity="jcwalker3",
|
||||
active_profile=None,
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "missing_active_profile")
|
||||
|
||||
def test_dirty_worktree_preserved_during_recovery(self):
|
||||
"""F-4: Compensating recovery does not delete dirty worktree."""
|
||||
branch = "fix/issue-850-rec-dirty"
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-rec-dirty")
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", "-b", branch, wt_path], check=True, capture_output=True)
|
||||
dirty_file = os.path.join(wt_path, "dirty.txt")
|
||||
with open(dirty_file, "w") as f:
|
||||
f.write("uncommitted work")
|
||||
|
||||
journal = {
|
||||
"idempotency_key": "test_dirty_rec",
|
||||
"issue_number": 850,
|
||||
"branch_name": branch,
|
||||
"worktree_path": wt_path,
|
||||
"artifacts_created": {
|
||||
"worktree_dir_created": True,
|
||||
"worktree_registered": True,
|
||||
},
|
||||
"failure_reason": "test dirty recovery",
|
||||
}
|
||||
rec = author_issue_bootstrap.run_compensating_recovery(journal, self.repo_dir, journal_dir=self.lock_dir)
|
||||
self.assertTrue(os.path.exists(wt_path))
|
||||
self.assertIn(f"worktree_path_preserved_dirty:{wt_path}", rec["rolled_back"])
|
||||
|
||||
def test_branch_with_commits_preserved_during_recovery(self):
|
||||
"""F-4: Compensating recovery does not delete branch with author commits."""
|
||||
branch = "fix/issue-850-rec-commits"
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", branch, self.master_sha], check=True, capture_output=True)
|
||||
# Add a commit on the branch
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-rec-commits")
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", wt_path, branch], check=True, capture_output=True)
|
||||
cfile = os.path.join(wt_path, "commit.txt")
|
||||
with open(cfile, "w") as f:
|
||||
f.write("author commit")
|
||||
subprocess.run(["git", "-C", wt_path, "add", "commit.txt"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", wt_path, "commit", "-m", "author commit"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "remove", "--force", wt_path], check=True, capture_output=True)
|
||||
|
||||
journal = {
|
||||
"idempotency_key": "test_commits_rec",
|
||||
"issue_number": 850,
|
||||
"branch_name": branch,
|
||||
"resolved_base_sha": self.master_sha,
|
||||
"artifacts_created": {
|
||||
"branch_created": True,
|
||||
},
|
||||
"failure_reason": "test commit branch recovery",
|
||||
}
|
||||
rec = author_issue_bootstrap.run_compensating_recovery(journal, self.repo_dir, journal_dir=self.lock_dir)
|
||||
branch_check = subprocess.run(["git", "-C", self.repo_dir, "rev-parse", "--verify", branch], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(branch_check.returncode, 0, "Branch with commits was deleted!")
|
||||
self.assertIn(f"branch_preserved_commits:{branch}", rec["rolled_back"])
|
||||
|
||||
def test_task_capability_map_integration(self):
|
||||
"""Verify task_capability_map has bootstrap_author_issue_worktree configured correctly."""
|
||||
self.assertEqual(task_capability_map.required_role("bootstrap_author_issue_worktree"), "author")
|
||||
self.assertEqual(task_capability_map.required_permission("bootstrap_author_issue_worktree"), "gitea.branch.create")
|
||||
self.assertTrue(task_capability_map.preflight_task_matches("work_issue", "bootstrap_author_issue_worktree"))
|
||||
self.assertTrue(task_capability_map.preflight_task_matches("bootstrap_author_issue_worktree", "lock_issue"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -28,6 +28,21 @@ class TestPathUnderBranches(unittest.TestCase):
|
||||
amw.is_path_under_branches("/repo/other-checkout", self.ROOT)
|
||||
)
|
||||
|
||||
def test_unrelated_branches_dir_fails(self):
|
||||
self.assertFalse(
|
||||
amw.is_path_under_branches("/tmp/branches/evil", self.ROOT)
|
||||
)
|
||||
|
||||
def test_prefix_confusion_fails(self):
|
||||
self.assertFalse(
|
||||
amw.is_path_under_branches(f"{self.ROOT}/branches-other/foo", self.ROOT)
|
||||
)
|
||||
|
||||
def test_traversal_fails(self):
|
||||
self.assertFalse(
|
||||
amw.is_path_under_branches(f"{self.ROOT}/branches/../evil", self.ROOT)
|
||||
)
|
||||
|
||||
|
||||
class TestAssessAuthorMutationWorktree(unittest.TestCase):
|
||||
ROOT = "/repo/Gitea-Tools"
|
||||
|
||||
@@ -1266,6 +1266,378 @@ class TestSecondRemediationIntegration(unittest.TestCase):
|
||||
self.assertIn("delete_acknowledged", delete_actions[0])
|
||||
self.assertTrue(delete_actions[0].get("verified_absent"))
|
||||
|
||||
def test_issue_851_worktree_removed_when_remote_blocked_only_by_worktree_binding(self):
|
||||
"""#851: remote blocked by worktree_binding must not skip safe worktree removal.
|
||||
|
||||
Lifecycle: remove clean owned worktree → reassess ownership → delete
|
||||
remote only if independently safe. Unrelated entries stay untouched.
|
||||
"""
|
||||
from mcp_server import gitea_reconcile_merged_cleanups
|
||||
|
||||
target_branch = "fix/issue-844-exclude-epic-containers"
|
||||
foreign_branch = "fix/issue-999-unrelated-active"
|
||||
worktree_path = "/tmp/branches/fix-issue-844-exclude-epic-containers"
|
||||
ownership_calls = []
|
||||
remove_calls = []
|
||||
delete_api_calls = []
|
||||
|
||||
def fake_collect(**kwargs):
|
||||
ownership_calls.append(dict(kwargs))
|
||||
# Ownership is reassessed *after* independent worktree removal (#851).
|
||||
# Target worktree is already gone → no worktree_binding remains.
|
||||
# Foreign branch keeps an active author lease → remote delete blocked.
|
||||
if kwargs.get("branch") == foreign_branch:
|
||||
# Match session-bound org/repo + host used by the tool resolve path.
|
||||
return {
|
||||
"records": [
|
||||
{
|
||||
"category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
"status": "active",
|
||||
"remote": kwargs.get("remote") or "prgs",
|
||||
"host": kwargs.get("host") or "gitea.example.com",
|
||||
"org": kwargs.get("org") or "Scaled-Tech-Consulting",
|
||||
"repo": kwargs.get("repo") or "Gitea-Tools",
|
||||
"branch": foreign_branch,
|
||||
"reclaim_allowed": False,
|
||||
}
|
||||
],
|
||||
"inventory_error": False,
|
||||
}
|
||||
return {"records": [], "inventory_error": False}
|
||||
|
||||
def fake_remove(project_root, branch, worktree_path=None):
|
||||
remove_calls.append(
|
||||
{"branch": branch, "worktree_path": worktree_path}
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"message": f"removed worktree {worktree_path}",
|
||||
"worktree_path": worktree_path,
|
||||
}
|
||||
|
||||
def fake_probe(h, o, r, auth, br):
|
||||
return guard.classify_branch_readback_http_status(
|
||||
404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH
|
||||
)
|
||||
|
||||
def fake_api(method, url, auth, **kwargs):
|
||||
if method == "DELETE":
|
||||
delete_api_calls.append(url)
|
||||
return {}
|
||||
|
||||
report = {
|
||||
"entries": [
|
||||
{
|
||||
"pr_number": 848,
|
||||
"head_branch": target_branch,
|
||||
"remote_branch": {"safe_to_delete_remote": True},
|
||||
"local_worktree": {
|
||||
"safe_to_remove_worktree": True,
|
||||
"worktree_path": worktree_path,
|
||||
},
|
||||
},
|
||||
{
|
||||
"pr_number": 999,
|
||||
"head_branch": foreign_branch,
|
||||
"remote_branch": {"safe_to_delete_remote": True},
|
||||
"local_worktree": {
|
||||
"safe_to_remove_worktree": False,
|
||||
"worktree_path": None,
|
||||
},
|
||||
},
|
||||
],
|
||||
"reviewer_scratch_entries": [],
|
||||
}
|
||||
patch(
|
||||
"mcp_server.get_profile",
|
||||
return_value={
|
||||
"profile_name": "prgs-reconciler",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.branch.delete",
|
||||
"gitea.pr.close",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
},
|
||||
).start()
|
||||
patch("mcp_server.api_get_all", return_value=[]).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.build_reconciliation_report",
|
||||
return_value=report,
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees",
|
||||
return_value=[],
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed",
|
||||
return_value=(True, []),
|
||||
).start()
|
||||
patch("mcp_server.verify_preflight_purity", return_value=None).start()
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
side_effect=fake_collect,
|
||||
).start()
|
||||
patch("mcp_server._probe_remote_branch", side_effect=fake_probe).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.remove_local_worktree",
|
||||
side_effect=fake_remove,
|
||||
).start()
|
||||
self.mock_api.side_effect = fake_api
|
||||
|
||||
res = gitea_reconcile_merged_cleanups(
|
||||
dry_run=False,
|
||||
execute_confirmed=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(res.get("performed") or res.get("executed"))
|
||||
actions = res.get("actions") or []
|
||||
|
||||
remove_actions = [
|
||||
a for a in actions if a.get("action") == "remove_local_worktree"
|
||||
]
|
||||
self.assertEqual(len(remove_actions), 1, actions)
|
||||
self.assertTrue(remove_actions[0].get("success"))
|
||||
self.assertEqual(remove_calls[0]["branch"], target_branch)
|
||||
self.assertEqual(remove_calls[0]["worktree_path"], worktree_path)
|
||||
|
||||
# Target remote delete succeeds after worktree removal + reassessment.
|
||||
target_deletes = [
|
||||
a
|
||||
for a in actions
|
||||
if a.get("action") == "delete_remote_branch"
|
||||
and a.get("branch") == target_branch
|
||||
]
|
||||
self.assertEqual(len(target_deletes), 1, actions)
|
||||
self.assertTrue(target_deletes[0].get("success"))
|
||||
self.assertTrue(target_deletes[0].get("after_worktree_removal"))
|
||||
self.assertTrue(target_deletes[0].get("ownership_reassessed"))
|
||||
self.assertTrue(target_deletes[0].get("verified_absent"))
|
||||
|
||||
# Foreign branch remains protected (author lease) and is not deleted.
|
||||
foreign_deletes = [
|
||||
a
|
||||
for a in actions
|
||||
if a.get("action") == "delete_remote_branch"
|
||||
and a.get("branch") == foreign_branch
|
||||
]
|
||||
self.assertEqual(len(foreign_deletes), 1, actions)
|
||||
self.assertFalse(foreign_deletes[0].get("success"))
|
||||
self.assertEqual(
|
||||
foreign_deletes[0].get("blocker_kind"), "active_branch_ownership"
|
||||
)
|
||||
self.assertIn(
|
||||
guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
foreign_deletes[0].get("blocking_categories") or [],
|
||||
)
|
||||
# Only the target branch should hit the DELETE API.
|
||||
self.assertEqual(len(delete_api_calls), 1)
|
||||
|
||||
# Ownership collected for target (post-removal) and foreign; worktree
|
||||
# removal happened before target remote delete in the action log.
|
||||
target_idx = next(
|
||||
i
|
||||
for i, a in enumerate(actions)
|
||||
if a.get("action") == "remove_local_worktree"
|
||||
)
|
||||
delete_idx = next(
|
||||
i
|
||||
for i, a in enumerate(actions)
|
||||
if a.get("action") == "delete_remote_branch"
|
||||
and a.get("branch") == target_branch
|
||||
and a.get("success")
|
||||
)
|
||||
self.assertLess(target_idx, delete_idx)
|
||||
|
||||
def test_issue_851_dirty_worktree_not_removed_and_remote_stays_protected(self):
|
||||
"""#851: dirty/foreign worktrees remain protected; no unsafe cleanup."""
|
||||
from mcp_server import gitea_reconcile_merged_cleanups
|
||||
|
||||
branch = "fix/issue-851-dirty"
|
||||
remove_calls = []
|
||||
|
||||
def fake_collect(**kwargs):
|
||||
return {
|
||||
"records": [
|
||||
{
|
||||
"category": guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||
"status": "active",
|
||||
"remote": kwargs.get("remote") or "prgs",
|
||||
"host": kwargs.get("host") or "gitea.example.com",
|
||||
"org": kwargs.get("org") or "Scaled-Tech-Consulting",
|
||||
"repo": kwargs.get("repo") or "Gitea-Tools",
|
||||
"branch": branch,
|
||||
"reclaim_allowed": False,
|
||||
}
|
||||
],
|
||||
"inventory_error": False,
|
||||
}
|
||||
|
||||
report = {
|
||||
"entries": [
|
||||
{
|
||||
"pr_number": 851,
|
||||
"head_branch": branch,
|
||||
"remote_branch": {"safe_to_delete_remote": True},
|
||||
"local_worktree": {
|
||||
"safe_to_remove_worktree": False,
|
||||
"worktree_path": "/tmp/dirty-wt",
|
||||
},
|
||||
}
|
||||
],
|
||||
"reviewer_scratch_entries": [],
|
||||
}
|
||||
patch(
|
||||
"mcp_server.get_profile",
|
||||
return_value={
|
||||
"profile_name": "prgs-reconciler",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
},
|
||||
).start()
|
||||
patch("mcp_server.api_get_all", return_value=[]).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.build_reconciliation_report",
|
||||
return_value=report,
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees",
|
||||
return_value=[],
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed",
|
||||
return_value=(True, []),
|
||||
).start()
|
||||
patch("mcp_server.verify_preflight_purity", return_value=None).start()
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
side_effect=fake_collect,
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.remove_local_worktree",
|
||||
side_effect=lambda *a, **k: remove_calls.append(k) or {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
},
|
||||
).start()
|
||||
self.mock_api.side_effect = lambda *a, **k: {}
|
||||
|
||||
res = gitea_reconcile_merged_cleanups(
|
||||
dry_run=False,
|
||||
execute_confirmed=True,
|
||||
remote="prgs",
|
||||
)
|
||||
actions = res.get("actions") or []
|
||||
self.assertEqual(remove_calls, [])
|
||||
self.assertFalse(
|
||||
any(a.get("action") == "remove_local_worktree" for a in actions)
|
||||
)
|
||||
deletes = [
|
||||
a for a in actions if a.get("action") == "delete_remote_branch"
|
||||
]
|
||||
self.assertEqual(len(deletes), 1)
|
||||
self.assertFalse(deletes[0].get("success"))
|
||||
self.assertEqual(deletes[0].get("blocker_kind"), "active_branch_ownership")
|
||||
self.assertIn(
|
||||
guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||
deletes[0].get("blocking_categories") or [],
|
||||
)
|
||||
|
||||
def test_issue_851_idempotent_resume_when_worktree_already_absent(self):
|
||||
"""#851: partial failures remain resumable and idempotent."""
|
||||
from mcp_server import gitea_reconcile_merged_cleanups
|
||||
|
||||
branch = "fix/issue-851-resume"
|
||||
ownership_calls = []
|
||||
|
||||
def fake_collect(**kwargs):
|
||||
ownership_calls.append(kwargs)
|
||||
return {"records": [], "inventory_error": False}
|
||||
|
||||
def fake_remove(project_root, branch, worktree_path=None):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"message": f"worktree not found: {worktree_path}",
|
||||
}
|
||||
|
||||
def fake_probe(h, o, r, auth, br):
|
||||
return guard.classify_branch_readback_http_status(
|
||||
404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH
|
||||
)
|
||||
|
||||
report = {
|
||||
"entries": [
|
||||
{
|
||||
"pr_number": 851,
|
||||
"head_branch": branch,
|
||||
"remote_branch": {"safe_to_delete_remote": True},
|
||||
"local_worktree": {
|
||||
"safe_to_remove_worktree": True,
|
||||
"worktree_path": "/tmp/already-gone",
|
||||
},
|
||||
}
|
||||
],
|
||||
"reviewer_scratch_entries": [],
|
||||
}
|
||||
patch(
|
||||
"mcp_server.get_profile",
|
||||
return_value={
|
||||
"profile_name": "prgs-reconciler",
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
},
|
||||
).start()
|
||||
patch("mcp_server.api_get_all", return_value=[]).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.build_reconciliation_report",
|
||||
return_value=report,
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees",
|
||||
return_value=[],
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed",
|
||||
return_value=(True, []),
|
||||
).start()
|
||||
patch("mcp_server.verify_preflight_purity", return_value=None).start()
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
side_effect=fake_collect,
|
||||
).start()
|
||||
patch("mcp_server._probe_remote_branch", side_effect=fake_probe).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.remove_local_worktree",
|
||||
side_effect=fake_remove,
|
||||
).start()
|
||||
self.mock_api.side_effect = lambda *a, **k: {}
|
||||
|
||||
res = gitea_reconcile_merged_cleanups(
|
||||
dry_run=False,
|
||||
execute_confirmed=True,
|
||||
remote="prgs",
|
||||
)
|
||||
actions = res.get("actions") or []
|
||||
removes = [a for a in actions if a.get("action") == "remove_local_worktree"]
|
||||
deletes = [a for a in actions if a.get("action") == "delete_remote_branch"]
|
||||
self.assertEqual(len(removes), 1)
|
||||
self.assertFalse(removes[0].get("success"))
|
||||
self.assertEqual(len(deletes), 1)
|
||||
self.assertTrue(deletes[0].get("success"))
|
||||
self.assertTrue(deletes[0].get("after_worktree_removal"))
|
||||
self.assertTrue(ownership_calls)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
"""Cross-role allocation handoff consumable by independent workers (#843).
|
||||
|
||||
Regression coverage for the controller→required-role consume path:
|
||||
|
||||
* controller allocates author work; independent author adopts successfully
|
||||
* author adoption succeeds after allocating controller process exits
|
||||
* author adoption without sharing controller session identity
|
||||
* wrong-role adoption rejected
|
||||
* concurrent/second adoption rejected without state corruption
|
||||
* terminal allocation adoption rejected
|
||||
* successful adoption produces authoritative ownership evidence
|
||||
* genuine abandoned-lease recovery remains valid
|
||||
* process_work_queue / allocate results include consume identifiers
|
||||
* same-role allocation behavior remains compatible
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from allocator_service import (
|
||||
ALLOCATION_MODE_CROSS_ROLE,
|
||||
ALLOCATION_MODE_ROLE_SCOPED,
|
||||
OUTCOME_ASSIGNED,
|
||||
ROLE_AUTHOR,
|
||||
ROLE_CONTROLLER,
|
||||
ROLE_REVIEWER,
|
||||
WorkCandidate,
|
||||
allocate_next_work,
|
||||
)
|
||||
from control_plane_db import ControlPlaneDB, ForeignLeaseError, _ts, _utc_now
|
||||
import lease_lifecycle as ll
|
||||
|
||||
|
||||
class CrossRoleHandoffTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||
self.db = ControlPlaneDB(self.db_path)
|
||||
self.db.upsert_session(
|
||||
session_id="ctrl-session",
|
||||
role="controller",
|
||||
profile="prgs-controller",
|
||||
pid=99999999, # dead-looking pid
|
||||
)
|
||||
self.db.upsert_session(
|
||||
session_id="author-worker",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=os.getpid(),
|
||||
)
|
||||
self.db.upsert_session(
|
||||
session_id="author-worker-2",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=os.getpid(),
|
||||
)
|
||||
self.db.upsert_session(
|
||||
session_id="reviewer-worker",
|
||||
role="reviewer",
|
||||
profile="prgs-reviewer",
|
||||
pid=os.getpid(),
|
||||
)
|
||||
self.wt = self._tmp.name
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _ready_issue(self, number: int = 843, title: str = "handoff target") -> WorkCandidate:
|
||||
return WorkCandidate(
|
||||
kind="issue",
|
||||
number=number,
|
||||
labels=("status:ready", "type:bug"),
|
||||
title=title,
|
||||
priority=20,
|
||||
)
|
||||
|
||||
def _controller_allocate(self, number: int = 843, **kwargs):
|
||||
defaults = dict(
|
||||
db=self.db,
|
||||
session_id="ctrl-session",
|
||||
role=ROLE_CONTROLLER,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=[self._ready_issue(number)],
|
||||
apply=True,
|
||||
profile_name="prgs-controller",
|
||||
username="controller-user",
|
||||
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return allocate_next_work(**defaults)
|
||||
|
||||
def test_controller_allocates_author_independent_author_adopts(self) -> None:
|
||||
res = self._controller_allocate()
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(res["required_role"], ROLE_AUTHOR)
|
||||
self.assertIn("consume_allocation", res)
|
||||
consume = res["consume_allocation"]
|
||||
self.assertEqual(consume["tool"], "gitea_adopt_workflow_lease")
|
||||
self.assertEqual(consume["required_role"], ROLE_AUTHOR)
|
||||
self.assertFalse(consume["controller_session_required"])
|
||||
lid = res["assignment"]["lease_id"]
|
||||
self.assertEqual(consume["lease_id"], lid)
|
||||
self.assertIn(lid, res["next_valid_command"])
|
||||
|
||||
adopted = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertTrue(adopted["success"])
|
||||
self.assertEqual(adopted["outcome"], "adopted_cross_role_handoff")
|
||||
self.assertEqual(adopted["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(adopted["adopted_from_session_id"], "ctrl-session")
|
||||
raw = adopted["read_after_write"]
|
||||
self.assertEqual(raw["session_id"], "author-worker")
|
||||
self.assertEqual(raw["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(raw["status"], "active")
|
||||
self.assertEqual(raw["phase"], "adopted")
|
||||
|
||||
# Authoritative re-read
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["lease"]["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(state["assignment"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["provenance"]["handoff_status"], "adopted")
|
||||
|
||||
def test_author_adoption_after_controller_process_exits(self) -> None:
|
||||
res = self._controller_allocate(number=900)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
# Force owner_pid dead + freshness stale_dead_process
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE leases SET owner_pid = 99999999 WHERE lease_id = ?",
|
||||
(lid,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
fr = ll.classify_lease_freshness(
|
||||
state["lease"], pid_checker=lambda _p: False
|
||||
)
|
||||
self.assertEqual(fr["freshness"], "stale_dead_process")
|
||||
|
||||
adopted = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertEqual(adopted["outcome"], "adopted_cross_role_handoff")
|
||||
self.assertEqual(adopted["adopted_by_session_id"], "author-worker")
|
||||
# No abandon required
|
||||
state2 = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state2["lease"]["status"], "active")
|
||||
self.assertNotEqual(state2["lease"]["status"], "abandoned")
|
||||
|
||||
def test_adoption_without_sharing_controller_session_identity(self) -> None:
|
||||
res = self._controller_allocate(number=901)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
adopted = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertNotEqual(adopted["adopted_by_session_id"], "ctrl-session")
|
||||
self.assertFalse(adopted["same_owner"])
|
||||
self.assertEqual(adopted["adopted_from_session_id"], "ctrl-session")
|
||||
|
||||
def test_wrong_role_adoption_rejected(self) -> None:
|
||||
res = self._controller_allocate(number=902)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
with self.assertRaises(ll.LeaseLifecycleError) as ctx:
|
||||
ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="reviewer-worker",
|
||||
role=ROLE_REVIEWER,
|
||||
)
|
||||
self.assertIn("wrong role", str(ctx.exception).lower())
|
||||
# State unchanged
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "ctrl-session")
|
||||
self.assertIsNone(state["lease"].get("adopted_by_session_id") or None)
|
||||
self.assertEqual(state["provenance"]["handoff_status"], "pending")
|
||||
|
||||
def test_second_adoption_rejected_without_corruption(self) -> None:
|
||||
res = self._controller_allocate(number=903)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
first = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertEqual(first["outcome"], "adopted_cross_role_handoff")
|
||||
with self.assertRaises(ll.LeaseLifecycleError):
|
||||
ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker-2",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["lease"]["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(state["assignment"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["lease"]["status"], "active")
|
||||
|
||||
def test_terminal_allocation_adoption_rejected(self) -> None:
|
||||
res = self._controller_allocate(number=904)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
# Abandon as terminal
|
||||
proof = ll.AbandonProof(
|
||||
dead_process=True,
|
||||
missing_worktree=True,
|
||||
no_open_pr=True,
|
||||
no_live_mutation_risk=True,
|
||||
owner_pid=99999999,
|
||||
worktree_path="/nonexistent/for-843",
|
||||
)
|
||||
# Attach dead pid / missing wt for abandon eligibility
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE leases SET owner_pid = 99999999, worktree_path = ? WHERE lease_id = ?",
|
||||
("/nonexistent/for-843", lid),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
abandoned = ll.abandon_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
requester_session_id="author-worker",
|
||||
proof=proof,
|
||||
)
|
||||
self.assertEqual(abandoned["outcome"], "abandoned")
|
||||
with self.assertRaises(ll.LeaseLifecycleError) as ctx:
|
||||
ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
)
|
||||
self.assertIn("abandoned", str(ctx.exception).lower())
|
||||
|
||||
def test_successful_adoption_read_after_write_ownership(self) -> None:
|
||||
res = self._controller_allocate(number=905)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
adopted = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
raw = adopted["read_after_write"]
|
||||
self.assertEqual(raw["lease_id"], lid)
|
||||
self.assertEqual(raw["session_id"], "author-worker")
|
||||
self.assertEqual(raw["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(raw["adopted_from_session_id"], "ctrl-session")
|
||||
# Re-fetch proves durable write
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], raw["session_id"])
|
||||
self.assertEqual(
|
||||
state["lease"]["adopted_by_session_id"], raw["adopted_by_session_id"]
|
||||
)
|
||||
|
||||
def test_genuine_abandoned_recovery_still_valid(self) -> None:
|
||||
"""Same-role author lease abandoned remains reclaimable via abandon path."""
|
||||
same = allocate_next_work(
|
||||
self.db,
|
||||
session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=[self._ready_issue(906, "same-role")],
|
||||
apply=True,
|
||||
profile_name="prgs-author",
|
||||
username="author-user",
|
||||
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
|
||||
)
|
||||
self.assertEqual(same["outcome"], OUTCOME_ASSIGNED)
|
||||
lid = same["assignment"]["lease_id"]
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE leases SET owner_pid = 99999999, worktree_path = ? WHERE lease_id = ?",
|
||||
("/nonexistent/same-role", lid),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
proof = ll.AbandonProof(
|
||||
dead_process=True,
|
||||
missing_worktree=True,
|
||||
no_open_pr=True,
|
||||
no_live_mutation_risk=True,
|
||||
owner_pid=99999999,
|
||||
worktree_path="/nonexistent/same-role",
|
||||
)
|
||||
abandoned = ll.abandon_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
requester_session_id="author-worker-2",
|
||||
proof=proof,
|
||||
)
|
||||
self.assertEqual(abandoned["outcome"], "abandoned")
|
||||
# Foreign author cannot handoff-consume an abandoned non-handoff lease
|
||||
with self.assertRaises(ll.LeaseLifecycleError):
|
||||
ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker-2",
|
||||
role=ROLE_AUTHOR,
|
||||
)
|
||||
# Reclaim path still works for expired/abandoned after force-expire
|
||||
reclaimed = ll.reclaim_expired_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
session_id="author-worker-2",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertEqual(reclaimed["outcome"], "reclaimed")
|
||||
self.assertEqual(reclaimed["assignment"]["session_id"], "author-worker-2")
|
||||
|
||||
def test_allocate_payload_includes_consume_identifiers(self) -> None:
|
||||
res = self._controller_allocate(number=907)
|
||||
self.assertIn("consume_allocation", res)
|
||||
c = res["consume_allocation"]
|
||||
for key in (
|
||||
"tool",
|
||||
"lease_id",
|
||||
"assignment_id",
|
||||
"required_role",
|
||||
"required_profile",
|
||||
"required_namespace",
|
||||
"instructions",
|
||||
"handoff_status",
|
||||
):
|
||||
self.assertIn(key, c)
|
||||
self.assertEqual(c["required_namespace"], "gitea-author")
|
||||
self.assertEqual(c["required_profile"], "prgs-author")
|
||||
self.assertIn("gitea_adopt_workflow_lease", c["instructions"])
|
||||
self.assertTrue(res["lease_proof"]["cross_role_handoff"])
|
||||
self.assertEqual(res["lease_proof"]["handoff_status"], "pending")
|
||||
|
||||
def test_same_role_allocation_remains_compatible(self) -> None:
|
||||
res = allocate_next_work(
|
||||
self.db,
|
||||
session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=[self._ready_issue(908)],
|
||||
apply=True,
|
||||
profile_name="prgs-author",
|
||||
username="author-user",
|
||||
)
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertNotIn("consume_allocation", res)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
# No cross-role handoff provenance
|
||||
prov = state.get("provenance") or {}
|
||||
self.assertFalse(prov.get("cross_role_handoff"))
|
||||
# Owner resume still works
|
||||
resume = ll.adopt_lease(
|
||||
self.db,
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertTrue(resume["same_owner"])
|
||||
self.assertEqual(resume["outcome"], "adopted_owner_resume")
|
||||
|
||||
def test_inspect_points_required_role_at_consume(self) -> None:
|
||||
res = self._controller_allocate(number=909)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
decision = ll.inspect_lease(
|
||||
self.db, lid, caller_session_id="author-worker"
|
||||
)
|
||||
self.assertEqual(
|
||||
decision["safe_next_action"], ll.SAFE_CONSUME_CROSS_ROLE
|
||||
)
|
||||
self.assertFalse(decision["block"])
|
||||
self.assertEqual(decision["required_role"], ROLE_AUTHOR)
|
||||
|
||||
def test_db_cas_rejects_concurrent_second_consume(self) -> None:
|
||||
res = self._controller_allocate(number=910)
|
||||
lid = res["assignment"]["lease_id"]
|
||||
# First consume via DB layer directly
|
||||
first = self.db.adopt_lease(
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
provenance={
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "adopted",
|
||||
"required_role": "author",
|
||||
},
|
||||
)
|
||||
self.assertEqual(first["outcome"], "adopted_cross_role_handoff")
|
||||
# Second CAS must fail
|
||||
with self.assertRaises(ForeignLeaseError):
|
||||
self.db.adopt_lease(
|
||||
lease_id=lid,
|
||||
adopter_session_id="author-worker-2",
|
||||
role=ROLE_AUTHOR,
|
||||
worktree_path=self.wt,
|
||||
provenance={
|
||||
"cross_role_handoff": True,
|
||||
"handoff_status": "pending",
|
||||
"required_role": "author",
|
||||
},
|
||||
)
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
|
||||
|
||||
class MCPBoundaryAdoptRoleBindingTest(unittest.TestCase):
|
||||
"""#843 F1: MCP-boundary role binding for ``gitea_adopt_workflow_lease``.
|
||||
|
||||
The library-level wrong-role test calls ``lease_lifecycle.adopt_lease``
|
||||
directly. These tests prove the MCP entry point derives the adopter role
|
||||
authoritatively from the active authenticated profile and rejects any
|
||||
caller-supplied role that disagrees, so a reviewer/merger profile cannot
|
||||
consume an author handoff by passing ``role="author"``.
|
||||
"""
|
||||
|
||||
AUTHOR_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
REVIEWER_PROFILE = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
}
|
||||
MERGER_PROFILE = {
|
||||
"profile_name": "prgs-merger",
|
||||
"role": "merger",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
}
|
||||
FOREIGN_AUTHOR_PROFILE = {
|
||||
"profile_name": "dadeschools-author",
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||
self.db = ControlPlaneDB(self.db_path)
|
||||
self.db.upsert_session(
|
||||
session_id="ctrl-session",
|
||||
role="controller",
|
||||
profile="prgs-controller",
|
||||
pid=99999999,
|
||||
)
|
||||
self.db.upsert_session(
|
||||
session_id="author-worker",
|
||||
role="author",
|
||||
profile="prgs-author",
|
||||
pid=os.getpid(),
|
||||
)
|
||||
self.wt = self._tmp.name
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _ready_issue(self, number: int) -> WorkCandidate:
|
||||
return WorkCandidate(
|
||||
kind="issue",
|
||||
number=number,
|
||||
labels=("status:ready", "type:bug"),
|
||||
title="handoff target",
|
||||
priority=20,
|
||||
)
|
||||
|
||||
def _handoff_lease(self, number: int = 843) -> str:
|
||||
res = allocate_next_work(
|
||||
db=self.db,
|
||||
session_id="ctrl-session",
|
||||
role=ROLE_CONTROLLER,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
candidates=[self._ready_issue(number)],
|
||||
apply=True,
|
||||
profile_name="prgs-controller",
|
||||
username="controller-user",
|
||||
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
|
||||
)
|
||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
||||
self.assertEqual(res["required_role"], ROLE_AUTHOR)
|
||||
return res["assignment"]["lease_id"]
|
||||
|
||||
def _call_adopt_tool(self, profile: dict, **kwargs):
|
||||
import gitea_mcp_server as mcp_server
|
||||
|
||||
with (
|
||||
patch.object(mcp_server, "get_profile", return_value=profile),
|
||||
patch.object(
|
||||
mcp_server,
|
||||
"_control_plane_db_or_error",
|
||||
return_value=(self.db, []),
|
||||
),
|
||||
):
|
||||
return mcp_server.gitea_adopt_workflow_lease(
|
||||
remote="prgs", **kwargs
|
||||
)
|
||||
|
||||
def _assert_handoff_untouched(self, lease_id: str) -> None:
|
||||
state = self.db.get_lease_workflow_state(lease_id)
|
||||
self.assertEqual(state["lease"]["session_id"], "ctrl-session")
|
||||
self.assertIsNone(state["lease"].get("adopted_by_session_id") or None)
|
||||
self.assertEqual(state["lease"]["status"], "active")
|
||||
self.assertEqual(state["provenance"]["handoff_status"], "pending")
|
||||
|
||||
def test_reviewer_profile_cannot_consume_author_handoff_via_role_author(
|
||||
self,
|
||||
) -> None:
|
||||
lid = self._handoff_lease(920)
|
||||
result = self._call_adopt_tool(
|
||||
self.REVIEWER_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="reviewer-worker",
|
||||
role="author",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertEqual(result["profile_role_kind"], "reviewer")
|
||||
self.assertEqual(result["supplied_role"], "author")
|
||||
self.assertIn("does not match", result["reasons"][0])
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_merger_profile_cannot_consume_author_handoff_via_role_author(
|
||||
self,
|
||||
) -> None:
|
||||
lid = self._handoff_lease(921)
|
||||
result = self._call_adopt_tool(
|
||||
self.MERGER_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="merger-worker",
|
||||
role="author",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertEqual(result["profile_role_kind"], "merger")
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_reviewer_profile_rejected_without_role_argument(self) -> None:
|
||||
"""Even without a spoofed role, the profile-derived role binds."""
|
||||
lid = self._handoff_lease(922)
|
||||
result = self._call_adopt_tool(
|
||||
self.REVIEWER_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="reviewer-worker",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertIn("wrong role", result["reasons"][0].lower())
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_author_profile_mismatching_supplied_role_rejected(self) -> None:
|
||||
lid = self._handoff_lease(923)
|
||||
result = self._call_adopt_tool(
|
||||
self.AUTHOR_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="author-worker",
|
||||
role="reviewer",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertEqual(result["profile_role_kind"], "author")
|
||||
self.assertEqual(result["supplied_role"], "reviewer")
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_foreign_profile_name_rejected_for_author_handoff(self) -> None:
|
||||
"""Provenance required_profile binds even when the role matches."""
|
||||
lid = self._handoff_lease(924)
|
||||
result = self._call_adopt_tool(
|
||||
self.FOREIGN_AUTHOR_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="foreign-author-worker",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["outcome"], "blocked")
|
||||
self.assertIn("wrong profile", result["reasons"][0].lower())
|
||||
self._assert_handoff_untouched(lid)
|
||||
|
||||
def test_author_profile_consumes_author_handoff(self) -> None:
|
||||
lid = self._handoff_lease(925)
|
||||
result = self._call_adopt_tool(
|
||||
self.AUTHOR_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="author-worker",
|
||||
role="author",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["outcome"], "adopted_cross_role_handoff")
|
||||
self.assertEqual(result["adopted_by_session_id"], "author-worker")
|
||||
self.assertEqual(result["adopted_from_session_id"], "ctrl-session")
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
self.assertEqual(
|
||||
state["lease"]["adopted_by_session_id"], "author-worker"
|
||||
)
|
||||
self.assertEqual(state["assignment"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["provenance"]["handoff_status"], "adopted")
|
||||
|
||||
def test_author_profile_consumes_author_handoff_without_role_argument(
|
||||
self,
|
||||
) -> None:
|
||||
lid = self._handoff_lease(926)
|
||||
result = self._call_adopt_tool(
|
||||
self.AUTHOR_PROFILE,
|
||||
lease_id=lid,
|
||||
session_id="author-worker",
|
||||
worktree_path=self.wt,
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["outcome"], "adopted_cross_role_handoff")
|
||||
state = self.db.get_lease_workflow_state(lid)
|
||||
self.assertEqual(state["lease"]["session_id"], "author-worker")
|
||||
self.assertEqual(state["lease"]["role"], "author")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,6 +12,59 @@ import merged_cleanup_reconcile as mcr # noqa: E402
|
||||
|
||||
|
||||
class TestMergedCleanupAssessment(unittest.TestCase):
|
||||
def test_issue_851_plan_order_worktree_then_reassess_then_remote(self):
|
||||
"""#851 dry-run plan: remove worktree, reassess ownership, then remote."""
|
||||
plan = mcr.plan_cleanup_execution_order(
|
||||
remote_assessment={"safe_to_delete_remote": True},
|
||||
local_assessment={"safe_to_remove_worktree": True},
|
||||
)
|
||||
actions = [s["action"] for s in plan]
|
||||
self.assertEqual(
|
||||
actions,
|
||||
[
|
||||
"remove_local_worktree",
|
||||
"reassess_branch_ownership",
|
||||
"delete_remote_branch",
|
||||
],
|
||||
)
|
||||
self.assertEqual(plan[0]["phase"], 1)
|
||||
self.assertEqual(plan[-1]["phase"], 3)
|
||||
self.assertIn("independently_safe", plan[0]["reason"])
|
||||
self.assertIn("reassessment", plan[-1]["reason"])
|
||||
|
||||
def test_issue_851_plan_remote_only_when_worktree_not_safe(self):
|
||||
plan = mcr.plan_cleanup_execution_order(
|
||||
remote_assessment={"safe_to_delete_remote": True},
|
||||
local_assessment={"safe_to_remove_worktree": False},
|
||||
)
|
||||
self.assertEqual([s["action"] for s in plan], ["delete_remote_branch"])
|
||||
self.assertNotIn("reassess_branch_ownership", [s["action"] for s in plan])
|
||||
|
||||
def test_issue_851_plan_worktree_only_when_remote_not_safe(self):
|
||||
plan = mcr.plan_cleanup_execution_order(
|
||||
remote_assessment={"safe_to_delete_remote": False},
|
||||
local_assessment={"safe_to_remove_worktree": True},
|
||||
)
|
||||
self.assertEqual([s["action"] for s in plan], ["remove_local_worktree"])
|
||||
|
||||
def test_issue_851_entry_includes_planned_execution_order(self):
|
||||
entry = mcr.build_pr_cleanup_entry(
|
||||
pr={
|
||||
"number": 848,
|
||||
"title": "Closes #844",
|
||||
"body": "",
|
||||
"merged_at": "2026-07-23T00:00:00Z",
|
||||
"head": {"ref": "fix/issue-844-x", "sha": "a" * 40},
|
||||
},
|
||||
project_root="/tmp/not-a-real-root",
|
||||
open_pr_heads=set(),
|
||||
remote_branch_exists=True,
|
||||
head_on_master=True,
|
||||
delete_capability_allowed=True,
|
||||
)
|
||||
self.assertIn("planned_execution_order", entry)
|
||||
self.assertIsInstance(entry["planned_execution_order"], list)
|
||||
|
||||
def test_extract_linked_issue_from_closes(self):
|
||||
issue = mcr.extract_linked_issue(
|
||||
"feat: cleanup (Closes #269)",
|
||||
|
||||
@@ -139,6 +139,8 @@ EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset(
|
||||
"gitea_release_merger_pr_lease",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"bootstrap_author_issue_worktree",
|
||||
"gitea_bootstrap_author_issue_worktree",
|
||||
# #812 AC20: publishing an unpublished local head is author-only for the
|
||||
# same reason every other push is — it writes a branch to the remote.
|
||||
"publish_unpublished_branch",
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests for the Phase 1 operator console application shell (#638)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from webui import layout
|
||||
from webui.app import create_app
|
||||
from webui.nav import NAV_GROUPS, STUB_PAGES, nav_hrefs
|
||||
|
||||
|
||||
class TestShellNav(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def test_nav_group_labels_present(self):
|
||||
text = self.client.get("/").text
|
||||
for group in NAV_GROUPS:
|
||||
with self.subTest(group=group.label):
|
||||
self.assertIn(f">{group.label}<", text)
|
||||
|
||||
def test_phase1_group_labels_cover_expected_ia(self):
|
||||
labels = {group.label for group in NAV_GROUPS}
|
||||
for expected in (
|
||||
"Health",
|
||||
"Traffic",
|
||||
"Runtime/Sessions",
|
||||
"Projects",
|
||||
"Inventory",
|
||||
"Timeline",
|
||||
"Policy",
|
||||
"Insights",
|
||||
):
|
||||
with self.subTest(label=expected):
|
||||
self.assertIn(expected, labels)
|
||||
|
||||
def test_every_nav_href_resolves_to_a_get_route(self):
|
||||
app = create_app()
|
||||
get_paths = {
|
||||
route.path
|
||||
for route in app.routes
|
||||
if isinstance(route, Route) and "GET" in route.methods
|
||||
}
|
||||
for href in nav_hrefs():
|
||||
with self.subTest(href=href):
|
||||
self.assertIn(href, get_paths, f"nav href {href} has no GET route")
|
||||
|
||||
def test_legacy_hrefs_still_navigable(self):
|
||||
text = self.client.get("/").text
|
||||
for href in ("/queue", "/projects", "/prompts", "/runtime",
|
||||
"/audit", "/worktrees", "/leases", "/actions"):
|
||||
with self.subTest(href=href):
|
||||
self.assertIn(f'href="{href}"', text)
|
||||
|
||||
|
||||
class TestShellBadges(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def test_mode_badge_present(self):
|
||||
self.assertIn("mode: read-only", self.client.get("/").text)
|
||||
|
||||
def test_environment_badge_present(self):
|
||||
self.assertIn("env:", self.client.get("/").text)
|
||||
|
||||
def test_default_environment_is_local(self):
|
||||
self.assertEqual(layout.environment_label(), "local")
|
||||
|
||||
def test_remote_bind_reports_remote_environment(self):
|
||||
import os
|
||||
|
||||
prior = os.environ.get("WEBUI_HOST")
|
||||
os.environ["WEBUI_HOST"] = "10.0.0.5"
|
||||
try:
|
||||
self.assertEqual(layout.environment_label(), "remote")
|
||||
finally:
|
||||
if prior is None:
|
||||
os.environ.pop("WEBUI_HOST", None)
|
||||
else:
|
||||
os.environ["WEBUI_HOST"] = prior
|
||||
|
||||
def test_docs_link_present(self):
|
||||
text = self.client.get("/").text
|
||||
self.assertIn(layout.DOCS_URL, text)
|
||||
self.assertIn(">Docs<", text)
|
||||
|
||||
|
||||
class TestShellStubs(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def test_stub_routes_render_200(self):
|
||||
for path, (title, _desc) in STUB_PAGES.items():
|
||||
with self.subTest(path=path):
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 200, path)
|
||||
self.assertIn(title, response.text)
|
||||
self.assertIn("placeholder", response.text)
|
||||
|
||||
def test_stub_routes_are_read_only(self):
|
||||
for path in STUB_PAGES:
|
||||
with self.subTest(path=path):
|
||||
response = self.client.post(path)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
self.assertEqual(response.json()["error"], "read-only-mvp")
|
||||
|
||||
def test_stub_pages_carry_nav_and_badges(self):
|
||||
response = self.client.get("/inventory")
|
||||
self.assertIn("mode: read-only", response.text)
|
||||
self.assertIn('href="/queue"', response.text)
|
||||
|
||||
|
||||
class TestShellHome(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def test_home_summarizes_console(self):
|
||||
text = self.client.get("/").text
|
||||
self.assertIn("Operator console", text)
|
||||
self.assertIn("Phase 1", text)
|
||||
|
||||
def test_home_links_legacy_pages(self):
|
||||
text = self.client.get("/").text
|
||||
self.assertIn("MVP legacy pages", text)
|
||||
for href in ("/queue", "/audit", "/leases"):
|
||||
with self.subTest(href=href):
|
||||
self.assertIn(f'href="{href}"', text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
+154
-11
@@ -12,6 +12,7 @@ from starlette.routing import Route
|
||||
|
||||
from webui.deployment_boundary import deployment_snapshot
|
||||
from webui.layout import render_page
|
||||
from webui.nav import NAV_GROUPS, STUB_PAGES
|
||||
from webui.project_registry import (
|
||||
ProjectRegistry,
|
||||
RegistryError,
|
||||
@@ -45,6 +46,7 @@ from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as wo
|
||||
from webui.worktree_views import render_worktrees_page
|
||||
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
|
||||
from webui.runtime_views import render_runtime_page
|
||||
from webui.timeline import load_timeline, snapshot_to_dict as timeline_snapshot_to_dict
|
||||
from webui.system_health import (
|
||||
API_PATH as SYSTEM_HEALTH_API_PATH,
|
||||
load_system_health,
|
||||
@@ -65,24 +67,62 @@ def _stub_page(title: str, description: str) -> HTMLResponse:
|
||||
return HTMLResponse(render_page(title=title, body_html=body))
|
||||
|
||||
|
||||
_LEGACY_PAGES = (
|
||||
("/queue", "Queue", "live PR and issue dashboard (#429)"),
|
||||
("/projects", "Projects", "registry and onboarding (#427)"),
|
||||
("/prompts", "Prompts", "canonical workflow prompt library (#428)"),
|
||||
("/runtime", "Runtime", "MCP health and stale-runtime detection (#430)"),
|
||||
("/audit", "Audit", "final-report paste and validator preview (#431)"),
|
||||
("/worktrees", "Worktrees", "branch hygiene dashboard (#432)"),
|
||||
("/leases", "Leases", "collision and lease visibility (#433)"),
|
||||
("/actions", "Actions", "gated write-action framework (#434)"),
|
||||
)
|
||||
|
||||
|
||||
def _render_home_nav_groups() -> str:
|
||||
groups = []
|
||||
for group in NAV_GROUPS:
|
||||
items = "".join(
|
||||
f'<li><a href="{item.href}">{item.label}</a>'
|
||||
+ ("" if item.status == "live" else " <span class=\"muted\">(stub)</span>")
|
||||
+ "</li>"
|
||||
for item in group.items
|
||||
)
|
||||
groups.append(f"<h3>{group.label}</h3><ul>{items}</ul>")
|
||||
return "".join(groups)
|
||||
|
||||
|
||||
async def home(_request: Request) -> HTMLResponse:
|
||||
legacy = "".join(
|
||||
f"<li><strong>{label}</strong> — {desc} "
|
||||
f'(<a href="{href}">{href}</a>)</li>'
|
||||
for href, label, desc in _LEGACY_PAGES
|
||||
)
|
||||
body = (
|
||||
"<h2>Operator console</h2>"
|
||||
"<p>Local entry point for MCP Control Plane operational views.</p>"
|
||||
"<ul>"
|
||||
"<li><strong>Queue</strong> — live PR and issue dashboard (#429)</li>"
|
||||
"<li><strong>Projects</strong> — registry and onboarding (#427)</li>"
|
||||
"<li><strong>Prompts</strong> — canonical workflow prompt library (#428)</li>"
|
||||
"<li><strong>Runtime</strong> — MCP health and stale-runtime detection (#430)</li>"
|
||||
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
|
||||
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
|
||||
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
|
||||
"<li><strong>Actions</strong> — gated write-action framework (#434)</li>"
|
||||
"</ul>"
|
||||
"<p>Read-only home for the MCP Control Plane Phase 1 operator console. "
|
||||
"Gitea, MCP capability gates, and canonical workflows remain the source "
|
||||
"of truth; this console never mutates them.</p>"
|
||||
"<h2>Phase 1 surfaces</h2>"
|
||||
+ _render_home_nav_groups()
|
||||
+ "<h2>MVP legacy pages</h2>"
|
||||
"<ul>" + legacy + "</ul>"
|
||||
)
|
||||
return HTMLResponse(render_page(title="Home", body_html=body))
|
||||
|
||||
|
||||
async def phase_stub(request: Request) -> HTMLResponse:
|
||||
"""Graceful read-only placeholder for a not-yet-implemented Phase 1 surface."""
|
||||
title, description = STUB_PAGES[request.url.path]
|
||||
body = (
|
||||
f"<h2>{title}</h2>"
|
||||
f'<div class="stub"><p>{description}</p>'
|
||||
"<p>Phase 1 shell placeholder — no write actions. Tracked under "
|
||||
"epic #631.</p></div>"
|
||||
)
|
||||
return HTMLResponse(render_page(title=title, body_html=body))
|
||||
|
||||
|
||||
async def health(_request: Request) -> JSONResponse:
|
||||
"""Liveness only — deliberately cheap, runs no dependency probe (#634).
|
||||
|
||||
@@ -410,6 +450,104 @@ async def api_console_security_model(_request: Request) -> JSONResponse:
|
||||
})
|
||||
|
||||
|
||||
def _query_int(request: Request, key: str) -> int | None:
|
||||
"""Parse an optional integer query parameter; None when absent/invalid."""
|
||||
raw = request.query_params.get(key)
|
||||
if raw is None or not str(raw).strip():
|
||||
return None
|
||||
try:
|
||||
return int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _derive_remote(host: str) -> str:
|
||||
"""Map a Gitea host to its known short remote name (control-plane scope key)."""
|
||||
text = (host or "").lower()
|
||||
if "prgs" in text:
|
||||
return "prgs"
|
||||
if "dadeschools" in text:
|
||||
return "dadeschools"
|
||||
return text.split(".")[0] if text else ""
|
||||
|
||||
|
||||
def _timeline_comment_source(host: str, org: str, repo: str):
|
||||
"""Build a fail-soft CTH-comment fetcher for one repo, or None when offline.
|
||||
|
||||
Returns a callable ``(kind, number) -> list[comment]``. Credentials or
|
||||
network failures raise inside the callable so ``load_timeline`` degrades the
|
||||
handoff source rather than the whole timeline. Offline test mode yields no
|
||||
live source so the handoff section reports ``not run``.
|
||||
"""
|
||||
import os
|
||||
|
||||
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
|
||||
|
||||
offline = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip().lower() in {"1", "true", "yes"}
|
||||
if offline:
|
||||
return None
|
||||
auth = get_auth_header(host)
|
||||
if not auth:
|
||||
return None
|
||||
|
||||
def _fetch(kind: str, number: int) -> list:
|
||||
segment = "pulls" if kind == "pr" else "issues"
|
||||
url = f"{repo_api_url(host, org, repo)}/{segment}/{int(number)}/comments"
|
||||
comments: list = []
|
||||
page = 1
|
||||
while page <= 20:
|
||||
raw, meta = api_fetch_page(url, auth, page=page, limit=50)
|
||||
comments.extend(raw)
|
||||
if bool(meta["is_final_page"]):
|
||||
break
|
||||
page += 1
|
||||
return comments
|
||||
|
||||
return _fetch
|
||||
|
||||
|
||||
async def api_v1_timeline(request: Request) -> JSONResponse:
|
||||
"""Read-only workflow-event timeline (#637). Filter by issue/PR/session."""
|
||||
from webui.queue_loader import _host_from_url # host normalisation helper
|
||||
|
||||
registry, error = _load_project_registry()
|
||||
if error is not None:
|
||||
return JSONResponse(error.to_dict(), status_code=500)
|
||||
project = registry.projects[0] if registry.projects else None
|
||||
|
||||
org = request.query_params.get("org") or (project.gitea_owner if project else "")
|
||||
repo = request.query_params.get("repo") or (project.repo_name if project else "")
|
||||
host = _host_from_url(project.remote_host) if project else ""
|
||||
remote = request.query_params.get("remote") or _derive_remote(host)
|
||||
|
||||
if not (remote and org and repo):
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "timeline_scope_unresolved",
|
||||
"detail": "no project in registry and no remote/org/repo query params provided",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
comment_source = _timeline_comment_source(host, org, repo) if (host and org and repo) else None
|
||||
|
||||
snapshot = load_timeline(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue=_query_int(request, "issue"),
|
||||
pr=_query_int(request, "pr"),
|
||||
session=(request.query_params.get("session") or None),
|
||||
limit=_query_int(request, "limit"),
|
||||
offset=_query_int(request, "offset"),
|
||||
comment_source=comment_source,
|
||||
)
|
||||
# A filter no surviving source can carry is refused, not answered empty:
|
||||
# a 200 with zero events would tell the operator no such activity exists.
|
||||
status_code = 200 if snapshot.ok else 422
|
||||
return JSONResponse(timeline_snapshot_to_dict(snapshot), status_code=status_code)
|
||||
|
||||
|
||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||
path = request.url.path
|
||||
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
||||
@@ -449,6 +587,7 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||
Route("/api/prompts", api_prompts, methods=["GET"]),
|
||||
Route("/runtime", runtime, methods=["GET"]),
|
||||
Route("/api/runtime", api_runtime, methods=["GET"]),
|
||||
Route("/api/v1/timeline", api_v1_timeline, methods=["GET"]),
|
||||
Route("/audit", audit, methods=["GET", "POST"]),
|
||||
Route("/api/audit", api_audit, methods=["GET", "POST"]),
|
||||
Route("/worktrees", worktrees, methods=["GET"]),
|
||||
@@ -472,6 +611,10 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||
api_console_security_model,
|
||||
methods=["GET"],
|
||||
),
|
||||
*[
|
||||
Route(path, phase_stub, methods=["GET"])
|
||||
for path in STUB_PAGES
|
||||
],
|
||||
],
|
||||
exception_handlers={405: method_not_allowed},
|
||||
)
|
||||
|
||||
+94
-16
@@ -2,28 +2,66 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
NAV_ITEMS = (
|
||||
("/", "Home"),
|
||||
("/queue", "Queue"),
|
||||
("/projects", "Projects"),
|
||||
("/prompts", "Prompts"),
|
||||
("/runtime", "Runtime"),
|
||||
("/audit", "Audit"),
|
||||
("/worktrees", "Worktrees"),
|
||||
("/leases", "Leases"),
|
||||
("/actions", "Actions"),
|
||||
)
|
||||
import os
|
||||
|
||||
from webui.nav import NAV_GROUPS
|
||||
|
||||
MVP_NOTICE = (
|
||||
"Read-only MVP — Gitea, MCP tools, and canonical workflows remain the "
|
||||
"source of truth. No mutation endpoints."
|
||||
)
|
||||
|
||||
# Canonical docs entry point surfaced from the shell header (#638).
|
||||
DOCS_URL = (
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/src/branch/"
|
||||
"master/docs/webui-local-dev.md"
|
||||
)
|
||||
|
||||
_LOCAL_HOSTS = frozenset({"", "127.0.0.1", "localhost", "::1"})
|
||||
|
||||
|
||||
def environment_label() -> str:
|
||||
"""Classify the serving environment as ``local`` or ``remote`` (#638).
|
||||
|
||||
Derived from the same ``WEBUI_HOST`` default the app binds to; loopback
|
||||
hosts are ``local``, anything else is ``remote``. Read-only signal only.
|
||||
"""
|
||||
host = (os.environ.get("WEBUI_HOST", "127.0.0.1") or "").strip().lower()
|
||||
return "local" if host in _LOCAL_HOSTS else "remote"
|
||||
|
||||
|
||||
def _render_nav() -> str:
|
||||
groups_html = []
|
||||
for group in NAV_GROUPS:
|
||||
links = "".join(
|
||||
f'<a href="{item.href}"'
|
||||
+ (' class="nav-stub"' if item.status == "stub" else "")
|
||||
+ f'>{item.label}</a>'
|
||||
for item in group.items
|
||||
)
|
||||
groups_html.append(
|
||||
'<div class="nav-group">'
|
||||
f'<span class="nav-group-label">{group.label}</span>'
|
||||
f'<span class="nav-group-links">{links}</span>'
|
||||
"</div>"
|
||||
)
|
||||
return "".join(groups_html)
|
||||
|
||||
|
||||
def _render_badges() -> str:
|
||||
env = environment_label()
|
||||
return (
|
||||
'<div class="header-badges">'
|
||||
f'<span class="badge env-badge env-{env}">env: {env}</span>'
|
||||
'<span class="badge mode-badge">mode: read-only</span>'
|
||||
f'<a class="badge docs-link" href="{DOCS_URL}">Docs</a>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
||||
nav_links = "".join(
|
||||
f'<a href="{href}">{label}</a>' for href, label in NAV_ITEMS
|
||||
)
|
||||
nav_links = _render_nav()
|
||||
header_badges = _render_badges()
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -53,21 +91,58 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
||||
padding: 0.75rem 1.25rem;
|
||||
}}
|
||||
header h1 {{
|
||||
margin: 0 0 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}}
|
||||
.header-top {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}}
|
||||
.header-badges {{ display: inline-flex; flex-wrap: wrap; gap: 0.4rem; }}
|
||||
.env-badge.env-local {{ color: #8fd19e; border-color: #3d6b4a; }}
|
||||
.env-badge.env-remote {{ color: #e0c27a; border-color: #6b5730; }}
|
||||
.mode-badge {{ color: #9ec8f0; border-color: #3d5f7a; }}
|
||||
a.docs-link {{
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
text-decoration: none;
|
||||
text-transform: none;
|
||||
}}
|
||||
a.docs-link:hover {{ filter: brightness(1.12); }}
|
||||
nav {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
gap: 0.5rem 1.25rem;
|
||||
}}
|
||||
.nav-group {{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}}
|
||||
.nav-group-label {{
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}}
|
||||
.nav-group-links {{ display: inline-flex; flex-wrap: wrap; gap: 0.6rem; }}
|
||||
nav a {{
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}}
|
||||
nav a:hover {{ text-decoration: underline; }}
|
||||
nav a.nav-stub {{ color: var(--muted); }}
|
||||
nav a.nav-stub::after {{
|
||||
content: " ·stub";
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}}
|
||||
main {{
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
@@ -166,7 +241,10 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-top">
|
||||
<h1>MCP Control Plane</h1>
|
||||
{header_badges}
|
||||
</div>
|
||||
<nav>{nav_links}</nav>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
"""Navigation IA for the Phase 1 operator console shell (#638).
|
||||
|
||||
Single source of truth for the console navigation so ``webui/layout.py`` and
|
||||
the ``webui/app.py`` route table stay aligned with epic #631. Read-only: every
|
||||
destination is a GET view or a Phase 1 placeholder. No mutation links.
|
||||
|
||||
Nav groups follow the #631 Phase 1 information architecture: Health, Traffic,
|
||||
Runtime/Sessions, Projects, Inventory, Timeline, Policy (placeholder), and
|
||||
Insights (placeholder). Later-phase surfaces are declared as ``stub`` items and
|
||||
backed by ``STUB_PAGES`` so their nav links resolve to a graceful placeholder
|
||||
instead of a 404.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NavItem:
|
||||
"""A single navigation destination.
|
||||
|
||||
``status`` is ``"live"`` for implemented views and ``"stub"`` for Phase 1
|
||||
placeholders whose backing view lands in a later child issue.
|
||||
"""
|
||||
|
||||
href: str
|
||||
label: str
|
||||
status: str = "live"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NavGroup:
|
||||
label: str
|
||||
items: tuple[NavItem, ...]
|
||||
|
||||
|
||||
NAV_GROUPS: tuple[NavGroup, ...] = (
|
||||
NavGroup("Health", (
|
||||
NavItem("/health", "Liveness"),
|
||||
)),
|
||||
NavGroup("Traffic", (
|
||||
NavItem("/queue", "Queue"),
|
||||
NavItem("/leases", "Leases"),
|
||||
NavItem("/actions", "Actions"),
|
||||
)),
|
||||
NavGroup("Runtime/Sessions", (
|
||||
NavItem("/runtime", "Runtime health"),
|
||||
NavItem("/sessions", "Sessions", "stub"),
|
||||
)),
|
||||
NavGroup("Projects", (
|
||||
NavItem("/projects", "Projects"),
|
||||
)),
|
||||
NavGroup("Inventory", (
|
||||
NavItem("/inventory", "Inventory", "stub"),
|
||||
NavItem("/worktrees", "Worktrees"),
|
||||
)),
|
||||
NavGroup("Timeline", (
|
||||
NavItem("/timeline", "Timeline", "stub"),
|
||||
)),
|
||||
NavGroup("Policy", (
|
||||
NavItem("/policy", "Policy", "stub"),
|
||||
NavItem("/prompts", "Prompts"),
|
||||
)),
|
||||
NavGroup("Insights", (
|
||||
NavItem("/insights", "Insights", "stub"),
|
||||
NavItem("/audit", "Audit"),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
# Phase 1 placeholder destinations whose backing views land in later child
|
||||
# issues of epic #631. Each maps a path to (title, description). Routes are
|
||||
# registered so nav links resolve to a graceful, read-only stub page.
|
||||
STUB_PAGES: dict[str, tuple[str, str]] = {
|
||||
"/sessions": (
|
||||
"Sessions",
|
||||
"Active session, capability, and role inventory. Backed by the unified "
|
||||
"inventory API (#636) once it lands.",
|
||||
),
|
||||
"/inventory": (
|
||||
"Inventory",
|
||||
"Unified sessions, leases, locks, namespaces, and worktree inventory. "
|
||||
"Backed by the Phase 1 inventory API (#636).",
|
||||
),
|
||||
"/timeline": (
|
||||
"Timeline",
|
||||
"Workflow event timeline across issues and PRs. A later Phase 1 surface.",
|
||||
),
|
||||
"/policy": (
|
||||
"Policy",
|
||||
"Capability and role policy surface. Placeholder until a later phase.",
|
||||
),
|
||||
"/insights": (
|
||||
"Insights",
|
||||
"Aggregate operational insights and trends. Placeholder until a later "
|
||||
"phase.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def iter_nav_items():
|
||||
"""Yield every ``NavItem`` across all groups in declared order."""
|
||||
for group in NAV_GROUPS:
|
||||
for item in group.items:
|
||||
yield item
|
||||
|
||||
|
||||
def nav_hrefs() -> tuple[str, ...]:
|
||||
"""Return every navigation href in declared order."""
|
||||
return tuple(item.href for item in iter_nav_items())
|
||||
@@ -0,0 +1,906 @@
|
||||
"""Workflow-event and conversation timeline model (#637, Phase 1).
|
||||
|
||||
Operators cannot browse a unified timeline of workflow events, decisions,
|
||||
tool calls, and handoffs: the evidence is scattered across control-plane
|
||||
events, Gitea canonical handoff comments, and local logs. This module defines
|
||||
one durable, versioned event schema and per-source adapters that normalise
|
||||
those scattered records into a single ``WorkflowEvent`` stream, plus a
|
||||
read-only query layer (filter by issue / PR / session, stable ordering,
|
||||
pagination) that the ``/api/v1/timeline`` route serves.
|
||||
|
||||
Design rules honoured here:
|
||||
|
||||
- **Read-only.** Sources are read; nothing is mutated. The control-plane
|
||||
database is opened through a ``mode=ro`` URI so a missing or unwritable DB
|
||||
degrades to a reason instead of creating directories or running migrations.
|
||||
- **Fail-soft per source.** An unavailable source degrades to a status with a
|
||||
reason rather than raising, and a source that could not run is never
|
||||
rendered as an empty-and-healthy timeline.
|
||||
- **Answerable filters only.** Each source declares which filter dimensions it
|
||||
can actually answer. A filter dimension no source that ran can carry is
|
||||
refused with an explicit reason rather than silently matching nothing: an
|
||||
empty page from an unanswerable filter reads to an operator as "no such
|
||||
activity", which is a different — and false — statement.
|
||||
- **Redaction at the boundary, fail closed.** Every free-text field (event
|
||||
messages, redacted tool arguments, decision/proof text) is run through the
|
||||
console redaction policy before it leaves this module, and *before* any
|
||||
structured value is derived from it — evidence references are extracted from
|
||||
redacted text, then independently revalidated before serialization. An
|
||||
unredactable value becomes the placeholder, and a value that cannot be proven
|
||||
safe is dropped — an unredacted payload is never emitted, and a generation
|
||||
error never drops raw data to a caller or a log.
|
||||
- **Stable ordering.** Events sort by ``(timestamp, source_rank, event_key)``
|
||||
with a deterministic tiebreak, so pagination is stable across calls and
|
||||
events with equal or missing timestamps keep a fixed order.
|
||||
|
||||
Non-goals (from the issue): no full chat replay, no mutation of historical
|
||||
events, no unredacted tool-argument storage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
import control_plane_db
|
||||
from webui import console_redaction
|
||||
|
||||
# The schema is versioned so consumers can branch on shape. Bump on any
|
||||
# breaking change to WorkflowEvent's serialized form.
|
||||
TIMELINE_SCHEMA_VERSION = 1
|
||||
|
||||
# Known event sources and their deterministic ordering rank. When two events
|
||||
# carry the same timestamp, the source rank breaks the tie before the
|
||||
# per-source event key, so a control-plane event and a handoff comment minted
|
||||
# in the same second always sort in a fixed order.
|
||||
SOURCE_CONTROL_PLANE = "control_plane"
|
||||
SOURCE_GITEA_HANDOFF = "gitea_handoff"
|
||||
_SOURCE_RANK = {
|
||||
SOURCE_CONTROL_PLANE: 0,
|
||||
SOURCE_GITEA_HANDOFF: 1,
|
||||
}
|
||||
|
||||
# The filter dimensions the query layer accepts.
|
||||
FILTER_ISSUE = "issue"
|
||||
FILTER_PR = "pr"
|
||||
FILTER_SESSION = "session"
|
||||
|
||||
# Which dimensions each source can actually answer. This is a property of the
|
||||
# underlying records, not of the query code: the control-plane ``events`` table
|
||||
# is (event_id, work_item_id, event_type, message, created_at) and carries no
|
||||
# session identity at all, so no control-plane event can ever match a session
|
||||
# filter. A CTH handoff comment can declare its session as a field, so the
|
||||
# handoff source answers all three. Filtering on a dimension the surviving
|
||||
# sources cannot carry is refused in ``load_timeline`` rather than answered
|
||||
# with an empty page.
|
||||
_SOURCE_FILTER_SUPPORT: dict[str, tuple[str, ...]] = {
|
||||
SOURCE_CONTROL_PLANE: (FILTER_ISSUE, FILTER_PR),
|
||||
SOURCE_GITEA_HANDOFF: (FILTER_ISSUE, FILTER_PR, FILTER_SESSION),
|
||||
}
|
||||
|
||||
# Why a source cannot answer a dimension, for the refusal reason an operator reads.
|
||||
_SOURCE_FILTER_LIMITS: dict[tuple[str, str], str] = {
|
||||
(SOURCE_CONTROL_PLANE, FILTER_SESSION): (
|
||||
"control-plane events carry no session identity "
|
||||
"(the events table has no session column)"
|
||||
),
|
||||
}
|
||||
|
||||
# A timestamp far in the future so events with no parseable timestamp sort
|
||||
# last (after everything real) instead of first, without raising.
|
||||
_MISSING_TS_SORT = "9999-12-31T23:59:59Z"
|
||||
|
||||
|
||||
def _parse_ts(value: str | None) -> str | None:
|
||||
"""Normalise a timestamp to ``...Z`` UTC, or None when unparseable."""
|
||||
if not value:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
candidate = text[:-1] + "+00:00" if text.endswith("Z") else text
|
||||
try:
|
||||
parsed = datetime.fromisoformat(candidate)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _redact(value: Any) -> Any:
|
||||
"""Redact a single free-text field, failing closed to the placeholder."""
|
||||
if value is None:
|
||||
return None
|
||||
return console_redaction.redact_text(str(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowEvent:
|
||||
"""One normalised timeline event.
|
||||
|
||||
Every field is optional except ``source``/``event_type``/``event_key``
|
||||
because sources carry different subsets. The class is frozen so an adapted
|
||||
event is an immutable record; a consumer that needs a variant builds a new
|
||||
one rather than mutating history.
|
||||
"""
|
||||
|
||||
source: str
|
||||
event_type: str
|
||||
event_key: str
|
||||
timestamp: str | None = None
|
||||
actor: str | None = None
|
||||
role: str | None = None
|
||||
issue_number: int | None = None
|
||||
pr_number: int | None = None
|
||||
session_id: str | None = None
|
||||
tool_name: str | None = None
|
||||
decision: str | None = None
|
||||
message: str | None = None
|
||||
correlation_id: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
sensitive: bool = False
|
||||
|
||||
def sort_key(self) -> tuple[str, int, str]:
|
||||
return (
|
||||
self.timestamp or _MISSING_TS_SORT,
|
||||
_SOURCE_RANK.get(self.source, 99),
|
||||
self.event_key,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source": self.source,
|
||||
"event_type": self.event_type,
|
||||
"event_key": self.event_key,
|
||||
"timestamp": self.timestamp,
|
||||
"actor": self.actor,
|
||||
"role": self.role,
|
||||
"issue_number": self.issue_number,
|
||||
"pr_number": self.pr_number,
|
||||
"session_id": self.session_id,
|
||||
"tool_name": self.tool_name,
|
||||
"decision": self.decision,
|
||||
"message": self.message,
|
||||
"correlation_id": self.correlation_id,
|
||||
"evidence_refs": list(self.evidence_refs),
|
||||
"sensitive": self.sensitive,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Adapters — pure functions from a source's raw records to WorkflowEvents. #
|
||||
# Each is total: a malformed record is skipped, never raised on. #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Event types whose payload is treated as sensitive and always redaction-hard
|
||||
# (they can carry lease/session provenance or tool arguments).
|
||||
_SENSITIVE_EVENT_HINTS = ("lease", "capability", "token", "auth", "secret")
|
||||
|
||||
# Reference tokens (issue/PR/comment ids) and SHAs parsed out of proof text.
|
||||
_EVIDENCE_REF_RE = re.compile(r"(?:#|PR\s*#?|issue\s*#?|comment\s*#?)(\d+)", re.IGNORECASE)
|
||||
|
||||
# A commit reference is only recognised when the text *declares* it as one.
|
||||
# A bare lowercase hex run is not evidence of anything: at 40 characters it is
|
||||
# exactly the shape of a Gitea personal access token, and at 7 it also matches
|
||||
# ordinary words such as "defaced". Requiring an anchoring keyword keeps real
|
||||
# references ("commit abc1234", "at head a209756...", "base caaae9b6") usable
|
||||
# while refusing to lift an undeclared secret-shaped run out of free text.
|
||||
_SHA_RE = re.compile(
|
||||
r"(?i:\b(?:commit|sha|head|base|parent|revision|rev|merge[- ]base)\b[\s:=@#]*)"
|
||||
r"([0-9a-f]{7,40})\b"
|
||||
)
|
||||
|
||||
# Shapes a serialized evidence reference is allowed to take. Anything else is
|
||||
# dropped rather than emitted.
|
||||
_REF_ISSUE_SHAPE = re.compile(r"^#[0-9]{1,9}$")
|
||||
_REF_SHA_SHAPE = re.compile(r"^[0-9a-f]{7,40}$")
|
||||
|
||||
# A long undelimited hex run with no declaring context is treated as credential
|
||||
# material wherever it appears, never as an identifier.
|
||||
_BARE_SECRET_SHAPE = re.compile(r"^[0-9a-f]{32,}$")
|
||||
|
||||
# An event type reads like an identifier, but a stored one is externally
|
||||
# influenced: any producer that writes the control-plane ``events`` table
|
||||
# chooses the string. It reaches ``to_dict`` verbatim, so it is validated here
|
||||
# rather than trusted because of where it came from.
|
||||
_CP_EVENT_TYPE_SHAPE = re.compile(r"^[A-Za-z][A-Za-z0-9._:+-]{0,63}$")
|
||||
|
||||
# Emitted in place of a value that cannot be proven safe. Deliberately not a
|
||||
# plausible workflow type: an unsafe value is refused, never quietly rewritten
|
||||
# into a different valid-looking one that would misdescribe the record.
|
||||
UNSAFE_EVENT_TYPE = "unsafe:redacted"
|
||||
|
||||
# Emitted for a CTH heading that is not a declared member of ``CTH_TYPES``. The
|
||||
# contract is enforced on write (``format_cth_body``) and on assess; the read
|
||||
# path the timeline uses enforces it too rather than assuming it was.
|
||||
UNKNOWN_HANDOFF_EVENT_TYPE = "handoff:unrecognized"
|
||||
|
||||
# A source record id is a plain integer in both sources it comes from: the
|
||||
# control-plane ``events`` primary key and a Gitea comment id. ``event_key`` is
|
||||
# serialized verbatim and is the pagination tiebreak, so anything else is
|
||||
# refused rather than interpolated into it.
|
||||
_RECORD_ID_SHAPE = re.compile(r"^[0-9]{1,19}$")
|
||||
|
||||
|
||||
def _kind_to_numbers(kind: str | None, number: int | None) -> tuple[int | None, int | None]:
|
||||
"""Map a control-plane work-item (kind, number) to (issue_no, pr_no)."""
|
||||
if number is None:
|
||||
return (None, None)
|
||||
if kind == "pr":
|
||||
return (None, int(number))
|
||||
if kind == "issue":
|
||||
return (int(number), None)
|
||||
return (None, None)
|
||||
|
||||
|
||||
def _correlation_for(kind: str | None, number: int | None) -> str | None:
|
||||
if number is None or kind not in ("issue", "pr"):
|
||||
return None
|
||||
return f"{kind}#{number}"
|
||||
|
||||
|
||||
def _extract_evidence_refs(*texts: str | None) -> tuple[str, ...]:
|
||||
"""Extract issue/PR and declared-commit references from **redacted** text.
|
||||
|
||||
Callers must pass text that has already been through :func:`_redact`; this
|
||||
function derives a structured field from its input, so extracting ahead of
|
||||
redaction would republish whatever redaction was about to remove. Every
|
||||
reference is revalidated by :func:`_validated_evidence_refs` before it is
|
||||
serialized.
|
||||
"""
|
||||
refs: list[str] = []
|
||||
for text in texts:
|
||||
if not text:
|
||||
continue
|
||||
for match in _EVIDENCE_REF_RE.finditer(text):
|
||||
token = f"#{match.group(1)}"
|
||||
if token not in refs:
|
||||
refs.append(token)
|
||||
for match in _SHA_RE.finditer(text):
|
||||
token = match.group(1)
|
||||
if token not in refs:
|
||||
refs.append(token)
|
||||
return tuple(refs)
|
||||
|
||||
|
||||
def _validated_evidence_refs(refs: Iterable[str]) -> tuple[tuple[str, ...], bool]:
|
||||
"""Independently revalidate references immediately before serialization.
|
||||
|
||||
Extraction is not trusted on its own. A reference survives only when it has
|
||||
a known reference shape and is unchanged by a second redaction pass — a
|
||||
value the redaction policy would alter is credential material that must not
|
||||
be emitted as a structured field. A full 40-character SHA stays usable
|
||||
because extraction only accepts a hex run the source text explicitly
|
||||
declared as a commit. Returns ``(safe_refs, dropped_any)``; ``dropped_any``
|
||||
marks the event sensitive so the drop is visible rather than silent.
|
||||
"""
|
||||
safe: list[str] = []
|
||||
dropped = False
|
||||
for ref in refs or ():
|
||||
try:
|
||||
token = str(ref).strip()
|
||||
if not token:
|
||||
continue
|
||||
recognised = bool(_REF_ISSUE_SHAPE.match(token) or _REF_SHA_SHAPE.match(token))
|
||||
if not recognised:
|
||||
dropped = True
|
||||
continue
|
||||
if _redact(token) != token:
|
||||
dropped = True
|
||||
continue
|
||||
if token not in safe:
|
||||
safe.append(token)
|
||||
except Exception:
|
||||
# Fail closed: a reference that cannot be proven safe is dropped.
|
||||
dropped = True
|
||||
continue
|
||||
return (tuple(safe), dropped)
|
||||
|
||||
|
||||
def _safe_session_id(value: Any) -> str | None:
|
||||
"""Return a session identifier only when it is safe to emit.
|
||||
|
||||
The value is authoritative source data — a session the record names for
|
||||
itself — but it is still free text. It is dropped when redaction alters it
|
||||
or when it is a bare secret-shaped hex run, so a credential parked in a
|
||||
session field can never reach the payload or be echoed back by a filter.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if _BARE_SECRET_SHAPE.match(text):
|
||||
return None
|
||||
return text if _redact(text) == text else None
|
||||
|
||||
|
||||
def _safe_record_id(value: Any) -> str | None:
|
||||
"""Return a source record id only when it is a plain numeric identifier.
|
||||
|
||||
``event_key`` is serialized verbatim and is the deterministic pagination
|
||||
tiebreak, so an id is interpolated into it only when it has the shape both
|
||||
real sources actually produce. A record whose identity cannot be trusted is
|
||||
refused by the caller rather than keyed on.
|
||||
"""
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
text = str(value).strip()
|
||||
return text if _RECORD_ID_SHAPE.match(text) else None
|
||||
|
||||
|
||||
def _safe_cp_event_type(value: Any) -> tuple[str, bool]:
|
||||
"""Validate a stored control-plane event type. Returns ``(type, unsafe)``.
|
||||
|
||||
The stored value is externally influenced — whichever producer wrote the
|
||||
``events`` row chose the string — and ``to_dict`` serializes it verbatim, so
|
||||
it passes a boundary of its own instead of relying on the one ``message``
|
||||
passes. A value survives only when it is an ordinary identifier, is not a
|
||||
bare secret-shaped hex run, and is unchanged by a redaction pass. Anything
|
||||
else fails closed to :data:`UNSAFE_EVENT_TYPE`: the record stays visible as
|
||||
an audit entry, but the value itself is never republished — not verbatim,
|
||||
not partially sanitized, and not rewritten into some other valid-looking
|
||||
type that would misdescribe what happened.
|
||||
"""
|
||||
text = ("" if value is None else str(value)).strip()
|
||||
if not text:
|
||||
return ("", False)
|
||||
if _BARE_SECRET_SHAPE.match(text):
|
||||
return (UNSAFE_EVENT_TYPE, True)
|
||||
if not _CP_EVENT_TYPE_SHAPE.match(text):
|
||||
return (UNSAFE_EVENT_TYPE, True)
|
||||
if _redact(text) != text:
|
||||
return (UNSAFE_EVENT_TYPE, True)
|
||||
return (text, False)
|
||||
|
||||
|
||||
def _safe_echo(value: Any) -> Any:
|
||||
"""Guard a scalar that is echoed back rather than derived from a record.
|
||||
|
||||
Query scope and filter values are caller-supplied and are reflected in the
|
||||
response so an operator can see what was asked. Reflection is still
|
||||
emission: a value redaction would alter, or a bare secret-shaped hex run, is
|
||||
replaced by the placeholder instead of being echoed verbatim. Ordinary
|
||||
scope and filter values pass through untouched.
|
||||
"""
|
||||
if value is None or isinstance(value, (int, bool)):
|
||||
return value
|
||||
text = str(value)
|
||||
if _BARE_SECRET_SHAPE.match(text.strip()):
|
||||
return console_redaction.REDACTED
|
||||
return _redact(text)
|
||||
|
||||
|
||||
def adapt_cp_events(rows: Iterable[dict[str, Any]]) -> list[WorkflowEvent]:
|
||||
"""Adapt control-plane ``events`` rows (joined to work_items) into events.
|
||||
|
||||
Each row is expected to carry ``event_id``, ``event_type``, ``message``,
|
||||
``created_at`` and the joined work-item ``kind``/``number``. Rows missing
|
||||
an id or type are skipped so a partially written table never raises.
|
||||
"""
|
||||
events: list[WorkflowEvent] = []
|
||||
for row in rows or []:
|
||||
try:
|
||||
event_id = _safe_record_id(row.get("event_id"))
|
||||
raw_event_type = (row.get("event_type") or "").strip()
|
||||
if event_id is None or not raw_event_type:
|
||||
continue
|
||||
# The stored type is source data, not a trusted constant: validate
|
||||
# it before it is serialized, exactly as `message` below is redacted
|
||||
# before it is serialized.
|
||||
event_type, event_type_unsafe = _safe_cp_event_type(raw_event_type)
|
||||
kind = row.get("kind")
|
||||
number = row.get("number")
|
||||
issue_no, pr_no = _kind_to_numbers(kind, number)
|
||||
sensitive = event_type_unsafe or any(
|
||||
hint in raw_event_type.lower() for hint in _SENSITIVE_EVENT_HINTS
|
||||
)
|
||||
events.append(
|
||||
WorkflowEvent(
|
||||
source=SOURCE_CONTROL_PLANE,
|
||||
event_type=event_type,
|
||||
event_key=f"cp:{event_id}",
|
||||
timestamp=_parse_ts(row.get("created_at")),
|
||||
issue_number=issue_no,
|
||||
pr_number=pr_no,
|
||||
# No session_id: the control-plane events table is
|
||||
# (event_id, work_item_id, event_type, message, created_at)
|
||||
# and records no session. Inventing one from the work item
|
||||
# or the message text would be a guess, so this source
|
||||
# declares the session dimension unsupported instead
|
||||
# (_SOURCE_FILTER_SUPPORT) and the query layer refuses a
|
||||
# session filter it cannot honestly answer.
|
||||
message=_redact(row.get("message")),
|
||||
correlation_id=_correlation_for(kind, number),
|
||||
sensitive=sensitive,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# A single malformed row must not sink the whole adaptation.
|
||||
continue
|
||||
return events
|
||||
|
||||
|
||||
def adapt_cth_comments(
|
||||
comments: Iterable[dict[str, Any]],
|
||||
*,
|
||||
kind: str,
|
||||
number: int,
|
||||
) -> list[WorkflowEvent]:
|
||||
"""Adapt Gitea Canonical Thread Handoff (CTH) comments into events.
|
||||
|
||||
Only comments that parse as a CTH (``canonical_thread_handoff.parse_cth_comment``)
|
||||
become events; ordinary comments are ignored. ``kind``/``number`` scope the
|
||||
events to the issue or PR the comments belong to.
|
||||
"""
|
||||
# Imported lazily so this module has no import-time dependency on the
|
||||
# handoff parser when only the control-plane adapter is used.
|
||||
from canonical_thread_handoff import is_known_cth_type, parse_cth_comment
|
||||
|
||||
# ``kind``/``number`` are interpolated into event_key and correlation_id, so
|
||||
# they are normalised once here. A scope this adapter cannot express is
|
||||
# refused outright rather than serialized into an identifier.
|
||||
kind = (kind or "").strip().lower()
|
||||
if kind not in ("issue", "pr"):
|
||||
return []
|
||||
try:
|
||||
number = int(number)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
issue_no, pr_no = _kind_to_numbers(kind, number)
|
||||
correlation = _correlation_for(kind, number)
|
||||
events: list[WorkflowEvent] = []
|
||||
for comment in comments or []:
|
||||
try:
|
||||
body = comment.get("body") or ""
|
||||
parsed = parse_cth_comment(body)
|
||||
if not parsed:
|
||||
continue
|
||||
fields = parsed.get("fields") or {}
|
||||
cth_type = parsed.get("cth_type") or ""
|
||||
comment_id = _safe_record_id(comment.get("id"))
|
||||
if comment_id is None:
|
||||
continue
|
||||
# The CTH heading is free text: the parser accepts whatever follows
|
||||
# "## CTH:", and only the write and assess paths check it against
|
||||
# the contract. Check it here too — an unrecognised heading is
|
||||
# reported as such rather than serialized into event_type, so
|
||||
# arbitrary, malformed, or secret-shaped heading content has no way
|
||||
# through. Declared types are preserved exactly.
|
||||
cth_type_known = is_known_cth_type(cth_type)
|
||||
# Redaction runs first, and every derived value is taken from the
|
||||
# redacted text — deriving evidence refs from the raw proof would
|
||||
# re-emit exactly what redaction was about to remove.
|
||||
decision = _redact(fields.get("decision"))
|
||||
proof = _redact(fields.get("proof"))
|
||||
next_action = _redact(fields.get("next action"))
|
||||
refs, refs_dropped = _validated_evidence_refs(
|
||||
_extract_evidence_refs(proof, decision)
|
||||
)
|
||||
events.append(
|
||||
WorkflowEvent(
|
||||
source=SOURCE_GITEA_HANDOFF,
|
||||
event_type=(
|
||||
f"handoff:{cth_type.strip()}"
|
||||
if cth_type_known
|
||||
else UNKNOWN_HANDOFF_EVENT_TYPE
|
||||
),
|
||||
event_key=f"cth:{kind}:{number}:{comment_id}",
|
||||
timestamp=_parse_ts(comment.get("created_at")),
|
||||
actor=_redact((comment.get("user") or {}).get("login")),
|
||||
role=_redact(fields.get("next owner")),
|
||||
issue_number=issue_no,
|
||||
pr_number=pr_no,
|
||||
# A CTH names its own session when the producer records one;
|
||||
# it is read from that declared field, never inferred from
|
||||
# unrelated text.
|
||||
session_id=_safe_session_id(fields.get("session")),
|
||||
decision=decision,
|
||||
message=next_action or _redact(fields.get("status")),
|
||||
correlation_id=correlation,
|
||||
evidence_refs=refs,
|
||||
sensitive=refs_dropped or not cth_type_known,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return events
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Read-only control-plane event source. #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_CP_EVENTS_QUERY = """
|
||||
SELECT e.event_id AS event_id,
|
||||
e.event_type AS event_type,
|
||||
e.message AS message,
|
||||
e.created_at AS created_at,
|
||||
w.kind AS kind,
|
||||
w.number AS number
|
||||
FROM events e
|
||||
JOIN work_items w ON e.work_item_id = w.work_item_id
|
||||
WHERE w.remote = ? AND w.org = ? AND w.repo = ?
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceStatus:
|
||||
"""Fail-soft status for one timeline source.
|
||||
|
||||
``supported_filters`` states which filter dimensions this source's records
|
||||
can carry; ``unsupported_filters`` names the requested dimensions it cannot,
|
||||
so an operator can see *why* a source contributed nothing rather than being
|
||||
left to read an empty list as an absence of activity.
|
||||
"""
|
||||
|
||||
name: str
|
||||
ok: bool
|
||||
reason: str | None = None
|
||||
count: int = 0
|
||||
supported_filters: tuple[str, ...] = ()
|
||||
unsupported_filters: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"ok": self.ok,
|
||||
"reason": self.reason,
|
||||
"count": self.count,
|
||||
"supported_filters": list(self.supported_filters),
|
||||
"unsupported_filters": list(self.unsupported_filters),
|
||||
}
|
||||
|
||||
|
||||
def _cp_status(*, ok: bool, reason: str | None = None, count: int = 0) -> SourceStatus:
|
||||
return SourceStatus(
|
||||
SOURCE_CONTROL_PLANE,
|
||||
ok=ok,
|
||||
# A failure reason is serialized like any other field and is often an
|
||||
# exception string carrying a path or a transport error, so it crosses
|
||||
# the redaction boundary too. Static reasons pass through unchanged.
|
||||
reason=_redact(reason),
|
||||
count=count,
|
||||
supported_filters=_SOURCE_FILTER_SUPPORT[SOURCE_CONTROL_PLANE],
|
||||
)
|
||||
|
||||
|
||||
def _handoff_status(*, ok: bool, reason: str | None = None, count: int = 0) -> SourceStatus:
|
||||
return SourceStatus(
|
||||
SOURCE_GITEA_HANDOFF,
|
||||
ok=ok,
|
||||
# Same boundary as the control-plane status: this reason can quote an
|
||||
# error raised by a live authenticated fetch.
|
||||
reason=_redact(reason),
|
||||
count=count,
|
||||
supported_filters=_SOURCE_FILTER_SUPPORT[SOURCE_GITEA_HANDOFF],
|
||||
)
|
||||
|
||||
|
||||
def read_cp_events(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
db_path: str | None = None,
|
||||
) -> tuple[list[WorkflowEvent], SourceStatus]:
|
||||
"""Read scoped control-plane events read-only. Never creates the DB.
|
||||
|
||||
Opens the SQLite file through a ``mode=ro`` URI: a health/timeline read
|
||||
must never create directories or run the schema migration that
|
||||
``ControlPlaneDB()`` performs on construction. A missing or unreadable DB
|
||||
degrades to a status with a reason.
|
||||
"""
|
||||
path = (db_path or control_plane_db.default_db_path()).strip()
|
||||
conn: sqlite3.Connection | None = None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(_CP_EVENTS_QUERY, (remote, org, repo))
|
||||
rows = [dict(r) for r in cursor.fetchall()]
|
||||
except sqlite3.OperationalError as exc:
|
||||
return ([], _cp_status(ok=False, reason=f"control-plane DB unavailable: {exc}"))
|
||||
except sqlite3.Error as exc:
|
||||
return ([], _cp_status(ok=False, reason=f"control-plane read failed: {exc}"))
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
events = adapt_cp_events(rows)
|
||||
return (events, _cp_status(ok=True, count=len(events)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Filter, sort, paginate. #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def filter_events(
|
||||
events: Iterable[WorkflowEvent],
|
||||
*,
|
||||
issue: int | None = None,
|
||||
pr: int | None = None,
|
||||
session: str | None = None,
|
||||
) -> list[WorkflowEvent]:
|
||||
"""Filter events by issue number, PR number, and/or session id.
|
||||
|
||||
Filters are conjunctive. A filter that names a dimension an event does not
|
||||
carry excludes that event (an issue filter excludes PR-only events).
|
||||
"""
|
||||
out: list[WorkflowEvent] = []
|
||||
for ev in events:
|
||||
if issue is not None and ev.issue_number != issue:
|
||||
continue
|
||||
if pr is not None and ev.pr_number != pr:
|
||||
continue
|
||||
if session is not None and ev.session_id != session:
|
||||
continue
|
||||
out.append(ev)
|
||||
return out
|
||||
|
||||
|
||||
def sort_events(events: Iterable[WorkflowEvent]) -> list[WorkflowEvent]:
|
||||
"""Return events in stable timeline order (ascending)."""
|
||||
return sorted(events, key=lambda ev: ev.sort_key())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimelinePage:
|
||||
"""One page of the sorted, filtered timeline."""
|
||||
|
||||
events: tuple[WorkflowEvent, ...]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
@property
|
||||
def next_offset(self) -> int | None:
|
||||
nxt = self.offset + len(self.events)
|
||||
return nxt if nxt < self.total else None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"events": [ev.to_dict() for ev in self.events],
|
||||
"pagination": {
|
||||
"total": self.total,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
"returned": len(self.events),
|
||||
"next_offset": self.next_offset,
|
||||
"has_more": self.next_offset is not None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_MAX_LIMIT = 500
|
||||
_DEFAULT_LIMIT = 50
|
||||
|
||||
|
||||
def _coerce_bounds(limit: int | None, offset: int | None) -> tuple[int, int]:
|
||||
try:
|
||||
lim = int(limit) if limit is not None else _DEFAULT_LIMIT
|
||||
except (TypeError, ValueError):
|
||||
lim = _DEFAULT_LIMIT
|
||||
try:
|
||||
off = int(offset) if offset is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
off = 0
|
||||
lim = max(1, min(lim, _MAX_LIMIT))
|
||||
off = max(0, off)
|
||||
return (lim, off)
|
||||
|
||||
|
||||
def paginate(events: list[WorkflowEvent], *, limit: int | None, offset: int | None) -> TimelinePage:
|
||||
lim, off = _coerce_bounds(limit, offset)
|
||||
window = events[off : off + lim]
|
||||
return TimelinePage(events=tuple(window), total=len(events), limit=lim, offset=off)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Composition — load_timeline aggregates all sources, fail-soft. #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# A comment source is a callable that, given (kind, number), returns the raw
|
||||
# Gitea comment list for that issue/PR. The route supplies a live fail-soft
|
||||
# fetcher; tests supply a fixture. When None, the handoff source is reported as
|
||||
# not-run (never silently empty-and-healthy).
|
||||
CommentSource = Callable[[str, int], list[dict[str, Any]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimelineSnapshot:
|
||||
"""One answered timeline query.
|
||||
|
||||
``ok`` is False when the query could not be answered as asked — currently
|
||||
when a requested filter dimension no surviving source can carry was
|
||||
supplied. The page is then empty *and* the snapshot says so, because an
|
||||
``ok`` empty page is a claim that no such activity exists.
|
||||
"""
|
||||
|
||||
schema_version: int
|
||||
remote: str
|
||||
org: str
|
||||
repo: str
|
||||
filters: dict[str, Any]
|
||||
page: TimelinePage
|
||||
sources: tuple[SourceStatus, ...]
|
||||
ok: bool = True
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"error": self.error,
|
||||
"schema_version": self.schema_version,
|
||||
# Scope and filters are echoed caller input, not derived record
|
||||
# data. Reflecting a value is still emitting it, so both cross the
|
||||
# same boundary; ordinary scope and filter values are unchanged.
|
||||
"scope": {
|
||||
"remote": _safe_echo(self.remote),
|
||||
"org": _safe_echo(self.org),
|
||||
"repo": _safe_echo(self.repo),
|
||||
},
|
||||
"filters": {key: _safe_echo(value) for key, value in self.filters.items()},
|
||||
"sources": [s.to_dict() for s in self.sources],
|
||||
**self.page.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
def _unanswerable_reasons(
|
||||
statuses: Iterable[SourceStatus], unanswerable: Iterable[str]
|
||||
) -> list[dict[str, str]]:
|
||||
"""Explain, per source, why each unanswerable dimension went unanswered."""
|
||||
out: list[dict[str, str]] = []
|
||||
for status in statuses:
|
||||
for dim in unanswerable:
|
||||
if dim not in status.supported_filters:
|
||||
reason = _SOURCE_FILTER_LIMITS.get(
|
||||
(status.name, dim), f"this source's records carry no {dim} identity"
|
||||
)
|
||||
elif not status.ok:
|
||||
reason = (
|
||||
f"this source can carry {dim} but did not run: "
|
||||
f"{status.reason or 'unavailable'}"
|
||||
)
|
||||
else:
|
||||
continue
|
||||
out.append({"source": status.name, "filter": dim, "reason": reason})
|
||||
return out
|
||||
|
||||
|
||||
def load_timeline(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue: int | None = None,
|
||||
pr: int | None = None,
|
||||
session: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
db_path: str | None = None,
|
||||
comment_source: CommentSource | None = None,
|
||||
) -> TimelineSnapshot:
|
||||
"""Aggregate every timeline source into one filtered, paginated snapshot.
|
||||
|
||||
Sources are read independently and fail soft: an unavailable source
|
||||
contributes a ``SourceStatus`` with ``ok=False`` and a reason, and never
|
||||
collapses the whole timeline. The handoff source only runs when a specific
|
||||
issue or PR is requested (a handoff comment belongs to one thread) and a
|
||||
``comment_source`` is available; otherwise it is reported as ``not run``
|
||||
rather than as an empty-and-healthy source.
|
||||
|
||||
A filter dimension that no surviving source can carry — a ``session``
|
||||
filter when the only source that ran is the control plane, whose events
|
||||
record no session — is refused with ``ok=False`` and a structured error
|
||||
instead of being answered with an empty page.
|
||||
"""
|
||||
all_events: list[WorkflowEvent] = []
|
||||
statuses: list[SourceStatus] = []
|
||||
|
||||
cp_events, cp_status = read_cp_events(remote=remote, org=org, repo=repo, db_path=db_path)
|
||||
all_events.extend(cp_events)
|
||||
statuses.append(cp_status)
|
||||
|
||||
# Gitea handoff comments are thread-scoped: only fetch when the caller
|
||||
# narrowed to one issue or PR, and only when a source was provided.
|
||||
handoff_target: tuple[str, int] | None = None
|
||||
if pr is not None:
|
||||
handoff_target = ("pr", pr)
|
||||
elif issue is not None:
|
||||
handoff_target = ("issue", issue)
|
||||
|
||||
if handoff_target is None:
|
||||
statuses.append(
|
||||
_handoff_status(
|
||||
ok=False,
|
||||
reason="not run: handoff comments are thread-scoped; filter by issue or pr to include them",
|
||||
)
|
||||
)
|
||||
elif comment_source is None:
|
||||
statuses.append(
|
||||
_handoff_status(
|
||||
ok=False,
|
||||
reason="not run: no comment source configured for this timeline read",
|
||||
)
|
||||
)
|
||||
else:
|
||||
kind, number = handoff_target
|
||||
try:
|
||||
comments = comment_source(kind, number) or []
|
||||
handoff_events = adapt_cth_comments(comments, kind=kind, number=number)
|
||||
all_events.extend(handoff_events)
|
||||
statuses.append(_handoff_status(ok=True, count=len(handoff_events)))
|
||||
except Exception as exc: # fail soft: a fetch/parse error degrades this source only
|
||||
statuses.append(_handoff_status(ok=False, reason=f"handoff source failed: {exc}"))
|
||||
|
||||
requested = tuple(
|
||||
name
|
||||
for name, value in ((FILTER_ISSUE, issue), (FILTER_PR, pr), (FILTER_SESSION, session))
|
||||
if value is not None
|
||||
)
|
||||
statuses = [
|
||||
replace(
|
||||
status,
|
||||
unsupported_filters=tuple(
|
||||
dim for dim in requested if dim not in status.supported_filters
|
||||
),
|
||||
)
|
||||
for status in statuses
|
||||
]
|
||||
filters = {"issue": issue, "pr": pr, "session": session}
|
||||
|
||||
# A dimension is answerable only if a source that actually ran can carry it.
|
||||
# If none can, refuse: an empty page would assert "no such activity", which
|
||||
# is a claim this timeline is not in a position to make.
|
||||
answerable: set[str] = set()
|
||||
for status in statuses:
|
||||
if status.ok:
|
||||
answerable.update(status.supported_filters)
|
||||
unanswerable = tuple(dim for dim in requested if dim not in answerable)
|
||||
|
||||
if unanswerable:
|
||||
return TimelineSnapshot(
|
||||
schema_version=TIMELINE_SCHEMA_VERSION,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
filters=filters,
|
||||
page=paginate([], limit=limit, offset=offset),
|
||||
sources=tuple(statuses),
|
||||
ok=False,
|
||||
error={
|
||||
"code": "filter_not_supported",
|
||||
"unsupported_filters": list(unanswerable),
|
||||
"detail": (
|
||||
"no timeline source that ran can answer "
|
||||
+ ", ".join(f"'{dim}'" for dim in unanswerable)
|
||||
+ "; the result is refused rather than returned empty"
|
||||
),
|
||||
"sources": _unanswerable_reasons(statuses, unanswerable),
|
||||
},
|
||||
)
|
||||
|
||||
filtered = filter_events(all_events, issue=issue, pr=pr, session=session)
|
||||
ordered = sort_events(filtered)
|
||||
page = paginate(ordered, limit=limit, offset=offset)
|
||||
|
||||
return TimelineSnapshot(
|
||||
schema_version=TIMELINE_SCHEMA_VERSION,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
filters=filters,
|
||||
page=page,
|
||||
sources=tuple(statuses),
|
||||
)
|
||||
|
||||
|
||||
def snapshot_to_dict(snapshot: TimelineSnapshot) -> dict[str, Any]:
|
||||
return snapshot.to_dict()
|
||||
Reference in New Issue
Block a user