feat: automate classification convergence
This commit is contained in:
parent
af68d438d6
commit
ff334f1a40
8 changed files with 346 additions and 11 deletions
7
Makefile
7
Makefile
|
|
@ -20,6 +20,13 @@ AGENT_VARS_FILE := $(RAILIANCE_INFRA)/ansible/inventory/group_vars/all.yaml
|
|||
ops-inventory-view: ## Render the ops-hub service catalog now view
|
||||
python3 ops/render_service_inventory.py
|
||||
|
||||
.PHONY: classification-status classification-check
|
||||
classification-status: ## Report active-fleet Repo Classification convergence
|
||||
python3 tools/repo_classification_convergence.py
|
||||
|
||||
classification-check: ## Fail until every active repo has a valid source and projection
|
||||
python3 tools/repo_classification_convergence.py --require-converged
|
||||
|
||||
.PHONY: custodian-keygen
|
||||
custodian-keygen: ## Generate custodian agent SSH keypair (one-time setup)
|
||||
@if [ -f "$(CUSTODIAN_KEY)" ]; then \
|
||||
|
|
|
|||
11
SCOPE.md
11
SCOPE.md
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
domain: custodian
|
||||
repo: the-custodian
|
||||
updated: "2026-07-08"
|
||||
updated: "2026-08-23"
|
||||
---
|
||||
|
||||
# SCOPE
|
||||
|
|
@ -87,9 +87,12 @@ their own repositories and are referenced here only as integration pointers.
|
|||
|
||||
## Current State
|
||||
|
||||
- Status: **stable maintenance** — governance substrate in daily ecosystem use.
|
||||
No `active`/`ready`/`blocked` CUST-WP workplans; one `proposed` plan
|
||||
(`CUST-WP-0055` terminology refactor) awaits review before pickup.
|
||||
- Status: **stable maintenance with two active coordination workplans** —
|
||||
`CUST-WP-0064` awaits the first unassisted weekday controlled-source SBOM
|
||||
fire; `CUST-WP-0065` awaits 13 repo-owner classification decisions while its
|
||||
live convergence check is automated in `make classification-status`. The
|
||||
active registry contains 116 repositories after archiving the already-retired
|
||||
Inter-Hub row.
|
||||
- Business platform enablement (`CUST-WP-0058`, finished 2026-07-10):
|
||||
DR-1/2/3 resolved (instance-per-client tenancy; coulomb.social as
|
||||
standalone app; app-local identity), business-app service contract
|
||||
|
|
|
|||
|
|
@ -409,7 +409,7 @@
|
|||
| task | CUST-WP-0063-T05 | done | — | workplans/CUST-WP-0063-inbox-governance-packets.md |
|
||||
| task | CUST-WP-0064-T01 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T02 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T03 | progress | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T03 | done | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0064-T04 | progress | — | workplans/CUST-WP-0064-sbom-controlled-scan-inputs.md |
|
||||
| task | CUST-WP-0065-T01 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
| task | CUST-WP-0065-T02 | done | — | workplans/CUST-WP-0065-reclassify-repos-and-guidance-docs-to-the-new-sector-domain.md |
|
||||
|
|
|
|||
|
|
@ -59,9 +59,12 @@ This table is a triage aid, not an automatic rewrite map.
|
|||
|
||||
## Reproduction
|
||||
|
||||
Read active repository records from `GET /repos/`, select records whose
|
||||
`status` is `active` and whose `category` is null, then test the registered
|
||||
`local_path` for `.repo-classification.yaml`. The observed counts are:
|
||||
Run `make classification-status`. The command reads active repository records
|
||||
from `GET /repos/`, compares every registered `local_path` with its
|
||||
`.repo-classification.yaml`, validates present source files against the canon,
|
||||
and reports owner-source gaps separately from projection gaps. Use
|
||||
`make classification-check` as the eventual fail-closed convergence gate.
|
||||
The initial observed counts were:
|
||||
|
||||
```text
|
||||
registered: 121
|
||||
|
|
@ -105,3 +108,12 @@ digest verification, and API projection passed. The current checkpoint is
|
|||
104/117 classified, 13 missing-source/null-category repos, and zero present
|
||||
source files left unprojected. This is governed pending work rather than an
|
||||
unexplained projection failure.
|
||||
|
||||
The first automated convergence run additionally found `inter-hub` still
|
||||
marked active with a projected classification even though its registered
|
||||
checkout had been removed. Custodian retirement evidence already records Core
|
||||
Hub as the sole production surface from 2026-07-08. The supported State Hub
|
||||
archive transition corrected the stale row while preserving its history. The
|
||||
current active denominator is therefore 116: 103 classified, 13
|
||||
missing-source/null-category, zero present-but-unprojected, and zero invalid
|
||||
source records.
|
||||
|
|
|
|||
83
tests/test_repo_classification_convergence.py
Normal file
83
tests/test_repo_classification_convergence.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tools.repo_classification_convergence import analyze_repositories, render_text
|
||||
|
||||
|
||||
def _record(slug: str, path: Path | None, *, category: str | None) -> dict:
|
||||
return {
|
||||
"slug": slug,
|
||||
"status": "active",
|
||||
"category": category,
|
||||
"local_path": str(path) if path else None,
|
||||
}
|
||||
|
||||
|
||||
def _write_valid(path: Path) -> None:
|
||||
path.mkdir()
|
||||
(path / ".repo-classification.yaml").write_text(
|
||||
"""repo_classification:
|
||||
category: project
|
||||
domain: infotech
|
||||
secondary_domains: []
|
||||
capability_tags: []
|
||||
business_stake: []
|
||||
business_mechanics: []
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_report_separates_owner_gap_from_projection_gap(tmp_path: Path) -> None:
|
||||
classified = tmp_path / "classified"
|
||||
unprojected = tmp_path / "unprojected"
|
||||
_write_valid(classified)
|
||||
_write_valid(unprojected)
|
||||
|
||||
report = analyze_repositories(
|
||||
[
|
||||
_record("classified", classified, category="project"),
|
||||
_record("owner-gap", tmp_path / "owner-gap", category=None),
|
||||
_record("projection-gap", unprojected, category=None),
|
||||
{"slug": "archived", "status": "archived", "category": None},
|
||||
]
|
||||
)
|
||||
|
||||
assert report["registered"] == 4
|
||||
assert report["active"] == 3
|
||||
assert report["active_classified"] == 1
|
||||
assert report["missing_source"] == ["owner-gap"]
|
||||
assert report["present_unprojected"] == ["projection-gap"]
|
||||
assert report["converged"] is False
|
||||
|
||||
|
||||
def test_report_flags_stale_projection_and_invalid_source(tmp_path: Path) -> None:
|
||||
invalid = tmp_path / "invalid"
|
||||
invalid.mkdir()
|
||||
(invalid / ".repo-classification.yaml").write_text(
|
||||
"repo_classification:\n category: made-up\n domain: custodian\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = analyze_repositories(
|
||||
[
|
||||
_record("stale", tmp_path / "stale", category="project"),
|
||||
_record("invalid", invalid, category="project"),
|
||||
]
|
||||
)
|
||||
|
||||
assert report["projected_without_source"] == ["stale"]
|
||||
assert "invalid" in report["invalid_source"]
|
||||
assert report["converged"] is False
|
||||
|
||||
|
||||
def test_clean_active_fleet_converges_and_renders(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
_write_valid(repo)
|
||||
report = analyze_repositories([_record("repo", repo, category="project")])
|
||||
|
||||
assert report["converged"] is True
|
||||
assert report["source_warning_count"] == 0
|
||||
assert "active classified: 1" in render_text(report)
|
||||
assert "converged: yes" in render_text(report)
|
||||
204
tools/repo_classification_convergence.py
Normal file
204
tools/repo_classification_convergence.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Report active-fleet Repo Classification convergence from State Hub.
|
||||
|
||||
The repository file remains authoritative. This command compares the live
|
||||
State Hub projection with each active record's registered local checkout and
|
||||
separates missing owner decisions from projection failures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from tools.validate_repo_classification import load_allowed, validate # noqa: E402
|
||||
|
||||
DEFAULT_API_BASE = "http://127.0.0.1:8000"
|
||||
UrlOpen = Callable[..., Any]
|
||||
|
||||
|
||||
class FleetReadError(RuntimeError):
|
||||
"""State Hub did not return a usable repository projection."""
|
||||
|
||||
|
||||
def fetch_repositories(
|
||||
api_base: str,
|
||||
*,
|
||||
timeout: float = 15.0,
|
||||
opener: UrlOpen = urllib.request.urlopen,
|
||||
) -> list[dict[str, Any]]:
|
||||
url = f"{api_base.rstrip('/')}/repos/?limit=500"
|
||||
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
try:
|
||||
with opener(request, timeout=timeout) as response:
|
||||
payload = json.load(response)
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
raise FleetReadError(f"cannot read State Hub repositories: {exc}") from exc
|
||||
if not isinstance(payload, list) or any(not isinstance(item, dict) for item in payload):
|
||||
raise FleetReadError("State Hub /repos response is not a list of objects")
|
||||
return payload
|
||||
|
||||
|
||||
def _source_path(record: dict[str, Any]) -> Path | None:
|
||||
local_path = record.get("local_path")
|
||||
if not isinstance(local_path, str) or not local_path.strip():
|
||||
return None
|
||||
return Path(local_path) / ".repo-classification.yaml"
|
||||
|
||||
|
||||
def analyze_repositories(
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
validate_sources: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
active = [record for record in records if record.get("status") == "active"]
|
||||
classified = [record for record in active if record.get("category") is not None]
|
||||
null_records = [record for record in active if record.get("category") is None]
|
||||
missing_source: list[str] = []
|
||||
present_unprojected: list[str] = []
|
||||
projected_without_source: list[str] = []
|
||||
invalid_source: dict[str, list[str]] = {}
|
||||
source_warnings: dict[str, list[str]] = {}
|
||||
duplicate_slugs: list[str] = []
|
||||
|
||||
slug_counts: dict[str, int] = {}
|
||||
allowed = load_allowed() if validate_sources else None
|
||||
for record in active:
|
||||
slug = str(record.get("slug") or "<missing-slug>")
|
||||
slug_counts[slug] = slug_counts.get(slug, 0) + 1
|
||||
path = _source_path(record)
|
||||
source_present = path is not None and path.is_file()
|
||||
|
||||
if record.get("category") is None:
|
||||
(present_unprojected if source_present else missing_source).append(slug)
|
||||
elif not source_present:
|
||||
projected_without_source.append(slug)
|
||||
|
||||
if validate_sources and source_present and path is not None:
|
||||
try:
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
errors, warnings = validate(document, allowed)
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
errors, warnings = [f"cannot read/parse source: {exc}"], []
|
||||
if errors:
|
||||
invalid_source[slug] = errors
|
||||
if warnings:
|
||||
source_warnings[slug] = warnings
|
||||
|
||||
duplicate_slugs = sorted(slug for slug, count in slug_counts.items() if count > 1)
|
||||
missing_source.sort()
|
||||
present_unprojected.sort()
|
||||
projected_without_source.sort()
|
||||
|
||||
blocking = bool(
|
||||
missing_source
|
||||
or present_unprojected
|
||||
or projected_without_source
|
||||
or invalid_source
|
||||
or duplicate_slugs
|
||||
)
|
||||
return {
|
||||
"registered": len(records),
|
||||
"active": len(active),
|
||||
"active_classified": len(classified),
|
||||
"active_null_category": len(null_records),
|
||||
"missing_source": missing_source,
|
||||
"present_unprojected": present_unprojected,
|
||||
"projected_without_source": projected_without_source,
|
||||
"invalid_source": dict(sorted(invalid_source.items())),
|
||||
"source_warnings": dict(sorted(source_warnings.items())),
|
||||
"source_warning_count": sum(len(items) for items in source_warnings.values()),
|
||||
"duplicate_active_slugs": duplicate_slugs,
|
||||
"converged": not blocking,
|
||||
}
|
||||
|
||||
|
||||
def render_text(report: dict[str, Any], *, show_warnings: bool = False) -> str:
|
||||
lines = [
|
||||
f"registered: {report['registered']}",
|
||||
f"active: {report['active']}",
|
||||
f"active classified: {report['active_classified']}",
|
||||
f"active null category: {report['active_null_category']}",
|
||||
f"missing source file: {len(report['missing_source'])}",
|
||||
f"source present but unprojected: {len(report['present_unprojected'])}",
|
||||
f"projected without source file: {len(report['projected_without_source'])}",
|
||||
f"invalid source file: {len(report['invalid_source'])}",
|
||||
f"source validation warnings: {report['source_warning_count']}",
|
||||
f"duplicate active slug: {len(report['duplicate_active_slugs'])}",
|
||||
f"converged: {'yes' if report['converged'] else 'no'}",
|
||||
]
|
||||
for key in (
|
||||
"missing_source",
|
||||
"present_unprojected",
|
||||
"projected_without_source",
|
||||
"duplicate_active_slugs",
|
||||
):
|
||||
if report[key]:
|
||||
lines.append(f"{key}: {', '.join(report[key])}")
|
||||
for slug, errors in report["invalid_source"].items():
|
||||
lines.append(f"invalid_source[{slug}]: {'; '.join(errors)}")
|
||||
if show_warnings:
|
||||
for slug, warnings in report["source_warnings"].items():
|
||||
lines.append(f"source_warnings[{slug}]: {'; '.join(warnings)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--api-base",
|
||||
default=os.environ.get("STATE_HUB_API_BASE", DEFAULT_API_BASE),
|
||||
help="State Hub REST base URL (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument("--timeout", type=float, default=15.0)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
parser.add_argument(
|
||||
"--show-warnings",
|
||||
action="store_true",
|
||||
help="include individual non-blocking source validation warnings",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-source-validation",
|
||||
action="store_true",
|
||||
help="only compare file presence and projection state",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--require-converged",
|
||||
action="store_true",
|
||||
help="exit 1 while any active classification gap remains",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
records = fetch_repositories(args.api_base, timeout=args.timeout)
|
||||
report = analyze_repositories(
|
||||
records,
|
||||
validate_sources=not args.skip_source_validation,
|
||||
)
|
||||
except FleetReadError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(render_text(report, show_warnings=args.show_warnings))
|
||||
return 1 if args.require_converged and not report["converged"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -124,7 +124,7 @@ Process/database health, repository reads, and zero-restarter startup all pass.
|
|||
|
||||
```task
|
||||
id: CUST-WP-0064-T03
|
||||
status: progress
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6c645778-be59-57b1-bf44-d974a3a1e49f"
|
||||
```
|
||||
|
|
@ -143,8 +143,16 @@ It then projected the live oldest-three target set (`can-you-assist`,
|
|||
The existing unpaused Temporal schedule was reconciled unchanged at limit 3
|
||||
and operator-triggered: it froze exactly those targets, spawned zero tasks,
|
||||
created three provenance-bearing terminal `no-manifest` snapshots, and moved
|
||||
`never_count` 94 to 91. Keep this task open for sustainable projection as each
|
||||
new oldest-N batch is exposed.
|
||||
`never_count` 94 to 91.
|
||||
|
||||
**Done (2026-08-23):** after that controlled batch advanced, the next oldest
|
||||
three (`citation-work`, `clay-borg`, and `config-atlas`) were selected with
|
||||
matching `forgejo-archive-v1` references and full 40-character revisions. An
|
||||
in-worker read-only report probe on deployed Activity Core digest
|
||||
`sha256:9c611a394c117c8ccfe2fd813c0dda1943ea8a7444aa4bd4742d83712b2cf559`
|
||||
returned `selected_count=3` and `controlled_source_count=3`. This proves
|
||||
source-reference projection follows each newly exposed bounded batch rather
|
||||
than the attended target set; T04 retains only the first unassisted fire.
|
||||
|
||||
## Prove real daily freshness improvement
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,24 @@ unprojected. Project names and repository identities were not rewritten.
|
|||
After the first owner delivery, the live projection is 104/117 classified with
|
||||
13 active null-category records and again zero present-but-unprojected files.
|
||||
|
||||
**Repeatable convergence gate (2026-08-23):**
|
||||
`tools/repo_classification_convergence.py` now derives those counts directly
|
||||
from the State Hub registry, checks each active record's registered checkout,
|
||||
validates every present source file against the canonical vocabulary, and
|
||||
separates missing owner records, present-but-unprojected records, stale
|
||||
projections without a source file, invalid sources, and duplicate active slugs.
|
||||
`make classification-status` reports progress; `make classification-check`
|
||||
fails closed until all active gaps are resolved.
|
||||
|
||||
Its first run exposed a stale active registry row hidden by the former
|
||||
null-category-only check: retired `inter-hub` retained a projected category but
|
||||
its registered checkout no longer exists. Existing Custodian retirement
|
||||
evidence records Core Hub as the sole production surface since 2026-07-08, so
|
||||
the supported State Hub archive endpoint corrected that row without altering
|
||||
its historical classification. The active fleet is now 103/116 classified;
|
||||
the same 13 owner-source gaps remain, with no projection or source-validity
|
||||
failure.
|
||||
|
||||
## Indexing note
|
||||
|
||||
**Resolved (2026-08-23):** Repo Manager revisions `7a15f1d` and `7b9fdaa`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue