Files
Gitea-Tools/dependency_graph.py
T
sysadminandClaude Opus 4.8 0589ec8069 feat(control-plane): persist allocator dependency edges as durable state (Closes #784)
Umbrella #628 scope item 6 requires dependencies to be durable structured
state carrying source, target, type, blocking condition, completion
condition, current state, and evidence. Nothing stored any of that.

Dependency knowledge existed only as a per-run computation:
allocator_dependencies re-parsed the Depends: declaration out of every
issue body on every allocation, _allocator_candidates_from_gitea resolved
each reference against live issue state, and the result collapsed into two
in-memory WorkCandidate fields that classify_skip consumed and discarded.
Three consequences followed. Nothing could answer "what is waiting on #N"
without re-listing every open issue and re-parsing every body, so the
reverse edge automatic resumption needs did not exist in any form. Only
issue-blocked-by-issue was expressible, leaving the other six #628
relationships with nowhere to live. And no observation was recorded, so a
transient lookup failure and a real block were indistinguishable after the
fact.

Add the store:

- dependency_edges table under schema v4. Creating the table is itself the
  v3 to v4 migration: additive, idempotent, and it never touches the
  existing tables. Uniqueness is (scope, source, target, edge_type), so
  re-observation updates one row rather than appending duplicates.
- dependency_graph.py owns the vocabulary: the seven #628 relationship
  types, the three states, and fail-closed normalization for both plus
  endpoint kinds. An unrecognized value writes nothing rather than landing
  as unqueryable free text. Evidence is sanitized before storage, so no
  credential or endpoint URL can be persisted or read back.
- upsert_dependency_edge, list_dependency_edges, and
  record_dependency_edge_observation on ControlPlaneDB. Filtering by target
  makes reverse lookup a single query. State transitions append to the
  existing events table rather than a parallel audit table.
- The allocator persists what it already resolved. States map one-to-one
  from the resolver's met/unmet/unavailable partitions, so nothing is
  re-classified and unavailable evidence is never recorded as met.
- gitea_list_dependency_edges exposes stored edges read-only, gated on
  gitea.read, and is added to the documented inventory the #781 drift guard
  checks.

Selection is deliberately untouched: classify_skip still consumes the
in-memory dependency_unmet field. The write is best-effort and reports
failures through reasons, so a broken or absent store leaves allocation
behaving exactly as it did before — proven by allocating the same candidate
set through a store whose writes all raise and comparing the selection,
skip set, and candidate count.

Automatic blocking and resumption (#628 item 7), non-issue edge creation,
and defect auto-linking are later slices; this one only makes the graph
durable and queryable.

Tests: 31 new cases covering fresh-schema creation, a real v3-to-v4
migration with row retention, idempotent re-migration, enum rejection,
upsert idempotence, forward and reverse lookup, scope isolation, transition
events, redaction at rest, live allocation-run ingestion, write-failure
tolerance, and the tool's permission gate.

Verification: 4126 passed in the branch worktree. The 11 failures in
test_commit_payloads, test_issue_702_review_findings_f1_f6, test_mcp_server,
test_post_merge_moot_lease, and test_reconciler_supersession_close reproduce
identically on a clean detached checkout of master at 300e8acd, so they are
pre-existing and proven by baseline run, not introduced here.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 18:07:21 -04:00

308 lines
12 KiB
Python

"""Durable dependency-edge vocabulary for the control plane (#784, umbrella #628).
Umbrella #628 scope item 6 requires dependencies to be durable structured state
carrying source, target, type, blocking condition, completion condition, current
state, and evidence. Before this module the only dependency knowledge in the
system was the per-run parse performed by :mod:`allocator_dependencies`, which
collapsed into two in-memory ``WorkCandidate`` fields and was then discarded.
This module owns the vocabulary half of that store:
* the seven relationship types #628 enumerates;
* the three observation states, matching the outcome of
:func:`allocator_dependencies.resolve_dependency_state`;
* fail-closed normalization for both, plus for work kinds;
* the default blocking/completion condition text for each type;
* evidence sanitization, so no credential or endpoint ever reaches the store.
Persistence lives in :mod:`control_plane_db`; ingestion from a live allocation
run is :func:`record_issue_dependency_edges`. Nothing here changes allocator
selection — this slice records the graph, it does not act on it.
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Mapping
# --- Work kinds -------------------------------------------------------------
# Mirrors control_plane_db.WORK_KINDS. Declared locally so this module stays
# import-light and usable from the DB layer without a circular import.
WORK_KIND_ISSUE = "issue"
WORK_KIND_PR = "pr"
WORK_KINDS = frozenset({WORK_KIND_ISSUE, WORK_KIND_PR})
# --- Edge types (#628 scope item 6) -----------------------------------------
EDGE_ISSUE_BLOCKED_BY_ISSUE = "issue_blocked_by_issue"
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES = "pr_waiting_for_requested_changes"
EDGE_MERGE_WAITING_FOR_APPROVAL = "merge_waiting_for_approval"
EDGE_RECONCILIATION_WAITING_FOR_MERGE = "reconciliation_waiting_for_merge"
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE = "deployment_waiting_for_infrastructure"
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION = "acceptance_waiting_for_validation"
EDGE_TASK_WAITING_FOR_DEFECT_FIX = "task_waiting_for_defect_fix"
EDGE_TYPES: frozenset[str] = frozenset(
{
EDGE_ISSUE_BLOCKED_BY_ISSUE,
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES,
EDGE_MERGE_WAITING_FOR_APPROVAL,
EDGE_RECONCILIATION_WAITING_FOR_MERGE,
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE,
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION,
EDGE_TASK_WAITING_FOR_DEFECT_FIX,
}
)
# --- Edge states ------------------------------------------------------------
# Deliberately three-valued: unavailable evidence is never recorded as met,
# matching resolve_dependency_state's fail-closed contract (#758 AC6/AC7).
STATE_UNMET = "unmet"
STATE_MET = "met"
STATE_UNAVAILABLE = "unavailable"
EDGE_STATES: frozenset[str] = frozenset({STATE_UNMET, STATE_MET, STATE_UNAVAILABLE})
# Default condition text per edge type: (blocking_condition, completion_condition).
DEFAULT_CONDITIONS: dict[str, tuple[str, str]] = {
EDGE_ISSUE_BLOCKED_BY_ISSUE: (
"target issue is not closed",
"target issue is closed",
),
EDGE_PR_WAITING_FOR_REQUESTED_CHANGES: (
"requested changes are outstanding at the current head",
"requested changes are addressed at the current head",
),
EDGE_MERGE_WAITING_FOR_APPROVAL: (
"no approval exists at the current head",
"an approval exists at the current head",
),
EDGE_RECONCILIATION_WAITING_FOR_MERGE: (
"target pull request is not merged",
"target pull request is merged",
),
EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE: (
"required infrastructure is unavailable",
"required infrastructure is available",
),
EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION: (
"required validation evidence is missing",
"required validation evidence is recorded",
),
EDGE_TASK_WAITING_FOR_DEFECT_FIX: (
"blocking defect is unresolved or undeployed",
"blocking defect is fixed and the runtime carries the fix",
),
}
class DependencyGraphError(ValueError):
"""Base error for dependency-edge vocabulary violations."""
class InvalidEdgeTypeError(DependencyGraphError):
"""Raised when an edge type outside :data:`EDGE_TYPES` is supplied."""
class InvalidEdgeStateError(DependencyGraphError):
"""Raised when a state outside :data:`EDGE_STATES` is supplied."""
class InvalidEdgeEndpointError(DependencyGraphError):
"""Raised when an edge endpoint is not an assignable work unit."""
def normalize_edge_type(value: Any) -> str:
"""Return the canonical edge type, or raise fail-closed.
Unknown values are never coerced to a default: an unrecognized relationship
would be stored as an unqueryable free-text row and would silently break
reverse lookup for whichever consumer expected the real type.
"""
text = str(value or "").strip().lower()
if text not in EDGE_TYPES:
raise InvalidEdgeTypeError(
f"unknown dependency edge_type '{value}'; expected one of "
f"{sorted(EDGE_TYPES)} (fail closed)"
)
return text
def normalize_edge_state(value: Any) -> str:
"""Return the canonical edge state, or raise fail-closed."""
text = str(value or "").strip().lower()
if text not in EDGE_STATES:
raise InvalidEdgeStateError(
f"unknown dependency edge state '{value}'; expected one of "
f"{sorted(EDGE_STATES)} (fail closed)"
)
return text
def normalize_work_kind(value: Any) -> str:
"""Return the canonical work kind for an edge endpoint, or raise."""
text = str(value or "").strip().lower()
if text not in WORK_KINDS:
raise InvalidEdgeEndpointError(
f"dependency edge endpoint kind '{value}' is not assignable work; "
f"expected one of {sorted(WORK_KINDS)} (never raw incidents)"
)
return text
def default_conditions(edge_type: str) -> tuple[str, str]:
"""Return ``(blocking_condition, completion_condition)`` for *edge_type*."""
return DEFAULT_CONDITIONS[normalize_edge_type(edge_type)]
# --- Evidence sanitization --------------------------------------------------
_SECRET_KEY_PATTERN = re.compile(
r"token|secret|password|passwd|authorization|auth_header|credential|api_key"
r"|apikey|private_key|cookie|session_token",
re.IGNORECASE,
)
_URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
REDACTED = "[redacted]"
# Evidence is a small observation record; a deep or huge payload is a sign the
# caller is dumping API responses into the store.
_MAX_EVIDENCE_DEPTH = 6
_MAX_EVIDENCE_STRING = 2000
def sanitize_evidence(payload: Any, *, _depth: int = 0) -> Any:
"""Return *payload* with credentials and endpoint URLs removed.
Applies to every stored evidence record. Keys naming a secret are replaced
wholesale; any value containing a URL has the URL replaced, so an endpoint
can never be persisted or handed back through a read tool.
"""
if _depth > _MAX_EVIDENCE_DEPTH:
return REDACTED
if isinstance(payload, Mapping):
clean: dict[str, Any] = {}
for key, value in payload.items():
name = str(key)
if _SECRET_KEY_PATTERN.search(name):
clean[name] = REDACTED
else:
clean[name] = sanitize_evidence(value, _depth=_depth + 1)
return clean
if isinstance(payload, (list, tuple)):
return [sanitize_evidence(item, _depth=_depth + 1) for item in payload]
if isinstance(payload, str):
text = _URL_PATTERN.sub(REDACTED, payload)
if len(text) > _MAX_EVIDENCE_STRING:
text = text[:_MAX_EVIDENCE_STRING] + "…"
return text
if isinstance(payload, (int, float, bool)) or payload is None:
return payload
return sanitize_evidence(str(payload), _depth=_depth + 1)
# --- Ingestion from a live allocation run -----------------------------------
# Observed live state as recorded in evidence. The exact Gitea state string is
# not stored for the unmet case: resolve_dependency_state has already reduced
# "any live value other than closed" to unmet, and re-deriving it here would
# invent evidence the resolver never produced.
OBSERVED_CLOSED = "closed"
OBSERVED_NOT_CLOSED = "not_closed"
OBSERVED_UNAVAILABLE = "unavailable"
OBSERVATION_SOURCE_ALLOCATOR = "allocator_live_issue_lookup"
_OBSERVED_STATE_BY_EDGE_STATE = {
STATE_MET: OBSERVED_CLOSED,
STATE_UNMET: OBSERVED_NOT_CLOSED,
STATE_UNAVAILABLE: OBSERVED_UNAVAILABLE,
}
def _observation(state: str, *, observed_by: str | None, subject: str) -> dict[str, Any]:
return {
"observed_state": _OBSERVED_STATE_BY_EDGE_STATE[state],
"observation_source": OBSERVATION_SOURCE_ALLOCATOR,
"observed_by_session": observed_by,
"declaration": "Depends declaration in issue body",
"subject": subject,
}
def edges_from_dependency_resolution(
resolution: Mapping[str, Any],
*,
source_number: int,
observed_by: str | None = None,
) -> list[dict[str, Any]]:
"""Convert one resolver result into edge records ready for persistence.
*resolution* is the dict returned by
:func:`allocator_dependencies.resolve_dependency_state`. Its ``met`` /
``unmet`` / ``unavailable`` partitions map one-to-one onto the stored
states, so no dependency is re-classified here.
"""
subject = f"issue#{int(source_number)}"
blocking, completion = default_conditions(EDGE_ISSUE_BLOCKED_BY_ISSUE)
records: list[dict[str, Any]] = []
partitions: tuple[tuple[str, Iterable[Any]], ...] = (
(STATE_MET, resolution.get("met") or ()),
(STATE_UNMET, resolution.get("unmet") or ()),
(STATE_UNAVAILABLE, resolution.get("unavailable") or ()),
)
for state, refs in partitions:
for ref in refs:
records.append(
{
"source_kind": WORK_KIND_ISSUE,
"source_number": int(source_number),
"target_kind": WORK_KIND_ISSUE,
"target_number": int(ref),
"edge_type": EDGE_ISSUE_BLOCKED_BY_ISSUE,
"state": state,
"blocking_condition": blocking,
"completion_condition": completion,
"evidence": _observation(
state, observed_by=observed_by, subject=subject
),
}
)
return records
def record_issue_dependency_edges(
db: Any,
*,
remote: str,
org: str,
repo: str,
source_number: int,
resolution: Mapping[str, Any],
observed_by: str | None = None,
) -> list[str]:
"""Persist the edges implied by one candidate's dependency resolution.
Best-effort by contract: allocation correctness must not depend on this
store existing or being writable, so every failure is returned as a reason
string and never raised. The caller keeps using the in-memory resolution it
already holds.
"""
try:
records = edges_from_dependency_resolution(
resolution, source_number=source_number, observed_by=observed_by
)
except Exception as exc: # noqa: BLE001 — ingestion never breaks allocation
return [f"dependency edge ingestion skipped for issue#{source_number}: {exc}"]
reasons: list[str] = []
for record in records:
try:
db.upsert_dependency_edge(remote=remote, org=org, repo=repo, **record)
except Exception as exc: # noqa: BLE001 — see docstring
reasons.append(
f"dependency edge not persisted for issue#{source_number} → "
f"issue#{record['target_number']}: {exc}"
)
return reasons