docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
a0d684116a
commit
d3fb789505
48 changed files with 11079 additions and 0 deletions
|
|
@ -0,0 +1,517 @@
|
|||
.PHONY: start install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard dashboard-local sync check-primary primary-port-clear check check-local test test-python clean register-project register-codex-project register-mcp configure-codex bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run railiance-state-hub-headroom
|
||||
|
||||
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||
PYTHON ?= python3
|
||||
HELM ?= $(shell command -v helm 2>/dev/null || if [ -x "$$HOME/.local/bin/helm" ]; then printf "%s" "$$HOME/.local/bin/helm"; else printf "%s" "helm"; fi)
|
||||
KUBECTL ?= $(shell command -v kubectl 2>/dev/null || if [ -x "$$HOME/.local/bin/kubectl" ]; then printf "%s" "$$HOME/.local/bin/kubectl"; else printf "%s" "kubectl"; fi)
|
||||
|
||||
RAILIANCE_STATE_HUB_RELEASE ?= state-hub
|
||||
RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub
|
||||
RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub
|
||||
RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml
|
||||
RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1
|
||||
RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform
|
||||
RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests
|
||||
# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable.
|
||||
UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi)
|
||||
RMGR ?= $(shell command -v rmgr 2>/dev/null || if [ -x "$$HOME/repo-manager/.venv/bin/rmgr" ]; then printf "%s" "$$HOME/repo-manager/.venv/bin/rmgr"; else printf "%s" "rmgr"; fi)
|
||||
STATE_HUB_API_BASE ?= http://127.0.0.1:8000
|
||||
SYNC_PATH ?= .
|
||||
SYNC_PUSH ?= 1
|
||||
|
||||
start:
|
||||
@echo "# Normal production UI access (run in order)"
|
||||
@echo "make bridges # connect local :8000 to the railiance01 primary"
|
||||
@echo "make dashboard # verify the primary, then serve the UI on :3000"
|
||||
@echo "make sync # push file-backed records and reconcile the exact forge commit"
|
||||
@echo ""
|
||||
@echo "# Optional local MCP adapter"
|
||||
@echo "make mcp-http # local SSE adapter on :8001; not needed by the UI"
|
||||
@echo ""
|
||||
@echo "# Local fallback/development only"
|
||||
@echo "make api # local Postgres + API; conflicts with the production :8000 tunnel"
|
||||
@echo "make dashboard-local # dashboard against the deliberate local API"
|
||||
|
||||
install:
|
||||
$(UV) sync
|
||||
|
||||
dashboard/node_modules/.bin/observable: dashboard/package.json dashboard/package-lock.json
|
||||
cd dashboard && npm ci
|
||||
|
||||
dashboard-install: dashboard/node_modules/.bin/observable
|
||||
|
||||
dashboard-check: dashboard-install
|
||||
cd dashboard && npm run build
|
||||
|
||||
## Symlink the custodian CLI into ~/.local/bin so it's on PATH system-wide
|
||||
install-cli: install
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(shell pwd)/.venv/bin/custodian" ~/.local/bin/custodian
|
||||
ln -sf "$(shell pwd)/.venv/bin/statehub" ~/.local/bin/statehub
|
||||
@echo "Installed: custodian → $$(readlink -f ~/.local/bin/custodian)"
|
||||
@echo "Installed: statehub → $$(readlink -f ~/.local/bin/statehub)"
|
||||
@echo "Make sure ~/.local/bin is on your PATH:"
|
||||
@echo " echo 'export PATH=\"\$$HOME/.local/bin:\$$PATH\"' >> ~/.bashrc && source ~/.bashrc"
|
||||
|
||||
db:
|
||||
$(COMPOSE) up -d postgres
|
||||
|
||||
db-tools:
|
||||
$(COMPOSE) --profile tools up -d
|
||||
|
||||
migrate:
|
||||
$(UV) run alembic upgrade head
|
||||
|
||||
seed:
|
||||
$(UV) run python scripts/seed.py
|
||||
|
||||
## Start (or restart) the MCP SSE server on :8001 — primary transport for Claude Code.
|
||||
## Remote clients (e.g. COULOMBCORE) connect via the ops-bridge tunnel (port 18001).
|
||||
## Registration: claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}'
|
||||
mcp-http:
|
||||
@fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true
|
||||
MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py
|
||||
|
||||
## Require the production identity before serving the normal dashboard. This
|
||||
## prevents an accidental local `make api` from presenting an empty fallback DB
|
||||
## as the live State Hub.
|
||||
check-primary:
|
||||
@health="$$(curl -fsS --max-time 5 http://127.0.0.1:8000/state/health 2>/dev/null)" || { \
|
||||
echo "ERROR: State Hub primary is not reachable on 127.0.0.1:8000." >&2; \
|
||||
echo "Run 'make bridges' first." >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
|
||||
if [ "$$identity" != "primary/railiance01" ]; then \
|
||||
echo "ERROR: 127.0.0.1:8000 is '$$identity', not the primary/railiance01 State Hub." >&2; \
|
||||
echo "Stop the local API, then run 'make bridges'. Use 'make dashboard-local' only for intentional local development." >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
## Refuse to start the production tunnel when a non-primary API already owns
|
||||
## port 8000. With no listener, bridge is free to establish the tunnel.
|
||||
primary-port-clear:
|
||||
@if health="$$(curl -fsS --max-time 2 http://127.0.0.1:8000/state/health 2>/dev/null)"; then \
|
||||
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
|
||||
if [ "$$identity" != "primary/railiance01" ]; then \
|
||||
echo "ERROR: port 8000 is occupied by '$$identity'." >&2; \
|
||||
echo "Stop the local 'make api' process before starting the production tunnel." >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
dashboard: check-primary
|
||||
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
|
||||
$(MAKE) dashboard-install
|
||||
cd dashboard && npm run dev
|
||||
|
||||
## Deliberate local-development dashboard; bypasses the production identity gate.
|
||||
dashboard-local:
|
||||
@echo "WARNING: serving the dashboard against the local/fallback API on :8000."
|
||||
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
|
||||
$(MAKE) dashboard-install
|
||||
cd dashboard && npm run dev
|
||||
|
||||
## Fast work-record path. Repo Manager assigns only missing deterministic IDs,
|
||||
## verifies the primary/railliance01 bridge, and asks central to derive the
|
||||
## exact pushed Forgejo commit in one transactional request.
|
||||
sync:
|
||||
$(RMGR) sync --path "$(SYNC_PATH)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter 1 true yes,$(SYNC_PUSH)),--push,)
|
||||
|
||||
check: check-primary
|
||||
@echo "State Hub primary/railiance01 is healthy."
|
||||
|
||||
check-local:
|
||||
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
|
||||
|
||||
# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values
|
||||
# because a container has no checkout to read it from. Regenerate after any
|
||||
# canon vocabulary change; the -check target fails on drift so a release cannot
|
||||
# validate classification against a stale vocabulary.
|
||||
sync-classification-allowed:
|
||||
python3 scripts/sync_classification_allowed.py
|
||||
|
||||
check-classification-allowed:
|
||||
python3 scripts/sync_classification_allowed.py --check
|
||||
|
||||
# STATE-WP-0091: read-only surge-headroom check. Does not start Helm.
|
||||
# Live: make railiance-state-hub-headroom
|
||||
# Fixtures: make railiance-state-hub-headroom HEADROOM_FIXTURE=tests/fixtures/release_headroom/rev63-105m.json
|
||||
HEADROOM_FIXTURE ?=
|
||||
railiance-state-hub-headroom:
|
||||
@if [ -n "$(HEADROOM_FIXTURE)" ]; then \
|
||||
$(PYTHON) scripts/release_headroom_preflight.py --fixture "$(HEADROOM_FIXTURE)"; \
|
||||
else \
|
||||
$(PYTHON) scripts/release_headroom_preflight.py --kubectl "$(KUBECTL)"; \
|
||||
fi
|
||||
|
||||
railiance-state-hub-render: check-classification-allowed
|
||||
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG)
|
||||
|
||||
railiance-state-hub-client-dry-run:
|
||||
@set -e; \
|
||||
tmpdir="$$(mktemp -d)"; \
|
||||
trap 'rm -rf "$$tmpdir"' EXIT; \
|
||||
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"
|
||||
|
||||
railiance-state-hub-server-dry-run:
|
||||
@set -e; \
|
||||
tmpdir="$$(mktemp -d)"; \
|
||||
trap 'rm -rf "$$tmpdir"' EXIT; \
|
||||
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \
|
||||
$(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \
|
||||
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \
|
||||
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||
if $(KUBECTL) get namespace $(RAILIANCE_STATE_HUB_NAMESPACE) >/dev/null 2>&1; then \
|
||||
$(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=server -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \
|
||||
else \
|
||||
echo "Namespace $(RAILIANCE_STATE_HUB_NAMESPACE) does not exist; validating namespaced app manifests with client dry-run."; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \
|
||||
fi
|
||||
|
||||
test: test-python dashboard-check
|
||||
|
||||
test-python:
|
||||
TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \
|
||||
$(UV) run pytest -x -q
|
||||
|
||||
## Benchmark /state/summary revision cache (API must be running on :8000)
|
||||
benchmark-summary-cache:
|
||||
$(UV) run python scripts/benchmark_summary_cache.py
|
||||
|
||||
## ops-bridge managed tunnels
|
||||
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||
tunnels-up: primary-port-clear
|
||||
bridge up
|
||||
|
||||
tunnels-status:
|
||||
bridge status
|
||||
|
||||
## End-to-end check: verifies SSH process alive + remote port listening on COULOMBCORE.
|
||||
## Exits non-zero if any tunnel is not fully operational.
|
||||
tunnels-check:
|
||||
bridge check
|
||||
|
||||
## Ensure all ops-bridge tunnels are up and healthy.
|
||||
## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down.
|
||||
bridges: primary-port-clear
|
||||
@echo "==> Bringing up all tunnels..."
|
||||
bridge up
|
||||
@echo ""
|
||||
@echo "==> Tunnel status:"
|
||||
bridge status
|
||||
@echo ""
|
||||
@echo "==> Checking tunnel health..."
|
||||
bridge check
|
||||
|
||||
## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn.
|
||||
## This replaces anything on :8000, including the production State Hub tunnel.
|
||||
api: db
|
||||
@echo "WARNING: starting the local fallback API; this is not the railiance01 primary."
|
||||
@echo "Waiting for postgres..."; \
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do \
|
||||
nc -z 127.0.0.1 5432 2>/dev/null && break; \
|
||||
sleep 1; \
|
||||
done
|
||||
$(MAKE) migrate
|
||||
@fuser -k 8000/tcp 2>/dev/null && echo "Stopped running API" || true
|
||||
$(UV) run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000
|
||||
|
||||
## Register a project (Claude Code): make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance
|
||||
register-project:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-project DOMAIN=<domain> PROJECT_PATH=<path>"; exit 1)
|
||||
@test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1)
|
||||
scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)"
|
||||
|
||||
## Register a Codex project (AGENTS.md + HTTP API): make register-codex-project DOMAIN=capabilities PROJECT_PATH=/home/worsch/my-repo
|
||||
register-codex-project:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-codex-project DOMAIN=<domain> PROJECT_PATH=<path>"; exit 1)
|
||||
@test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1)
|
||||
scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" --codex
|
||||
|
||||
## Register State Hub MCP for Claude Code. Optional: make register-mcp MCP_URL=http://127.0.0.1:18001/sse
|
||||
register-mcp:
|
||||
scripts/register-mcp.sh \
|
||||
$(if $(MCP_URL),--url "$(MCP_URL)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
## Configure Codex workspace-write networking for local State Hub access.
|
||||
configure-codex:
|
||||
scripts/configure-codex.sh \
|
||||
$(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(WITH_MCP),--with-mcp,) \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
## Bootstrap a new operator/collaborator environment. Optional: make bootstrap-env ARGS="--install-missing"
|
||||
bootstrap-env:
|
||||
scripts/bootstrap-env.sh $(ARGS)
|
||||
|
||||
## Files-first local dev hub (CUST-WP-0054-T07): postgres + migrate + seed + API + repo registration.
|
||||
## Optional: WITH_EDGE=1 WITH_MCP=1 make dev-hub
|
||||
## Fleet tunnel when reachable: make dev-hub PROFILE=fleet
|
||||
dev-hub:
|
||||
bash scripts/dev_hub_up.sh $(if $(PROFILE),$(PROFILE),dev)
|
||||
|
||||
## Local edge relay for offline write buffering (STATE-WP-0068).
|
||||
edge-relay:
|
||||
@fuser -k 18080/tcp 2>/dev/null && echo "Stopped running edge relay" || true
|
||||
STATEHUB_UPSTREAM_URL=$${STATEHUB_UPSTREAM_URL:-http://127.0.0.1:8000} \
|
||||
$(UV) run uvicorn api.edge.relay:app --host 127.0.0.1 --port 18080
|
||||
|
||||
## Switch MCP/API profile: make mcp-profile PROFILE=dev|fleet|status
|
||||
mcp-profile:
|
||||
@test -n "$(PROFILE)" || (echo "ERROR: PROFILE is required (dev|fleet|status)."; exit 1)
|
||||
bash scripts/mcp_hub_profile.sh "$(PROFILE)"
|
||||
|
||||
## Add a second repo to an existing domain: make add-repo DOMAIN=railiance REPO_PATH=/home/worsch/railiance-infra
|
||||
add-repo:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
|
||||
@test -n "$(REPO_PATH)" || (echo "ERROR: REPO_PATH is required."; exit 1)
|
||||
scripts/register_project.sh "$(DOMAIN)" "$(REPO_PATH)" --additional
|
||||
|
||||
## Create a new domain: make add-domain DOMAIN=my_domain NAME="My Domain"
|
||||
add-domain:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required (slug)."; exit 1)
|
||||
@test -n "$(NAME)" || (echo "ERROR: NAME is required (display name)."; exit 1)
|
||||
curl -sf -X POST http://127.0.0.1:8000/domains/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"slug\": \"$(DOMAIN)\", \"name\": \"$(NAME)\"}" | python3 -m json.tool
|
||||
|
||||
## Rename a domain: make rename-domain DOMAIN=old_slug NEW_SLUG=new_slug NEW_NAME="New Name"
|
||||
rename-domain:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN (old slug) is required."; exit 1)
|
||||
@test -n "$(NEW_SLUG)" || (echo "ERROR: NEW_SLUG is required."; exit 1)
|
||||
@test -n "$(NEW_NAME)" || (echo "ERROR: NEW_NAME is required."; exit 1)
|
||||
curl -sf -X PATCH http://127.0.0.1:8000/domains/$(DOMAIN)/rename \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool
|
||||
|
||||
## Register this machine's local path for a repo: make register-path REPO=marki-docx PATH=/home/tegwick/marki-docx
|
||||
register-path:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
||||
@test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
||||
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool
|
||||
|
||||
## List repos for a domain: make list-repos DOMAIN=railiance
|
||||
list-repos:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
|
||||
curl -sf "http://127.0.0.1:8000/repos/?domain=$(DOMAIN)" | python3 -m json.tool
|
||||
|
||||
## Ingest SBOM data for a repo (all mechanisms: lockfiles + ansible + sbom-tools.yaml).
|
||||
## Auto-detect all sources: make ingest-sbom REPO=the-custodian REPO_PATH=/home/worsch/the-custodian
|
||||
## Single lockfile (explicit): make ingest-sbom REPO=the-custodian LOCKFILE=/path/to/uv.lock
|
||||
## Dry-run (no submit): make ingest-sbom REPO=the-custodian REPO_PATH=... DRY_RUN=1
|
||||
## Tip: run capture-tools first for repos with system-level tool dependencies.
|
||||
ingest-sbom:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/ingest_sbom.py --repo "$(REPO)" \
|
||||
$(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Ingest capability declarations from SCOPE.md into the catalog.
|
||||
## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian]
|
||||
## Or: make ingest-capabilities-all
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
ingest-capabilities:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/ingest_capabilities.py --repo "$(REPO)" \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
ingest-capabilities-all:
|
||||
$(UV) run python scripts/ingest_capabilities.py --all \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Check Repository Definition of Integrated (DoI) criteria for a repo.
|
||||
## Usage: make check-doi REPO=llm-connect
|
||||
## Or: make check-doi-all
|
||||
## Add JSON=1 for machine-readable output.
|
||||
check-doi:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json)
|
||||
|
||||
check-doi-all:
|
||||
$(UV) run python scripts/check_doi.py --all $(if $(JSON),--json)
|
||||
|
||||
## Ingest tpsc.yaml service declarations from a repo into the TPSC catalog.
|
||||
## Usage: make ingest-tpsc REPO=llm-connect
|
||||
## Or: make ingest-tpsc-all
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
ingest-tpsc:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/ingest_tpsc.py --repo "$(REPO)" \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
ingest-tpsc-all:
|
||||
$(UV) run python scripts/ingest_tpsc.py --all \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml.
|
||||
## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra]
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
capture-tools:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/capture_sbom_tools.py --repo "$(REPO)" \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian]
|
||||
validate-adr:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1)
|
||||
$(UV) run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",)
|
||||
|
||||
## Consistency exit contract:
|
||||
## - Direct scripts/consistency_check.py: 0 clean, 2 warnings-only, 1 failures.
|
||||
## - Agent/operator Make wrappers below normalize warning-only 2 to shell success
|
||||
## while preserving visible WARN output and keeping real failures non-zero.
|
||||
## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override]
|
||||
## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures
|
||||
check-consistency:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO=<slug>"; exit 1)
|
||||
$(UV) run python scripts/consistency_check.py --repo "$(REPO)" \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override]
|
||||
## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures
|
||||
fix-consistency:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO=<slug>"; exit 1)
|
||||
$(UV) run python scripts/consistency_check.py --repo "$(REPO)" --fix \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Normalize workplan frontmatter and task status literals in attached repos.
|
||||
## Usage: make normalize-attached-workplans REPO=artifact-store
|
||||
## make normalize-attached-workplans DIRTY=1
|
||||
normalize-attached-workplans:
|
||||
$(UV) run python scripts/normalize_attached_repo_workplans.py \
|
||||
$(if $(REPO),--repo "$(REPO)",) \
|
||||
$(if $(DIRTY),--dirty,) \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
@test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO=<slug> or DIRTY=1"; exit 1)
|
||||
|
||||
## Regenerate AGENTS.md / CLAUDE.md / .claude/rules from templates.
|
||||
## Usage: make update-agent-instructions REPO=artifact-store
|
||||
## make update-agent-instructions DIRTY=1
|
||||
update-agent-instructions:
|
||||
$(UV) run python scripts/update_agent_instruction_files.py \
|
||||
$(if $(REPO),--repo "$(REPO)",) \
|
||||
$(if $(DIRTY),--dirty,)
|
||||
@test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO=<slug> or DIRTY=1"; exit 1)
|
||||
|
||||
## Reconcile measured token sources against State Hub.
|
||||
## Usage: make token-reconcile [SINCE=2026-05-19] [APPLY=1] [ZERO_FALLBACKS=1]
|
||||
token-reconcile:
|
||||
$(PYTHON) scripts/token_reconcile.py \
|
||||
$(if $(SINCE),--since "$(SINCE)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \
|
||||
$(if $(CLAUDE_HOME),--claude-home "$(CLAUDE_HOME)",) \
|
||||
$(if $(APPLY),--apply,) \
|
||||
$(if $(ZERO_FALLBACKS),--zero-superseded-fallbacks,)
|
||||
|
||||
## Pull then fix: single repo or all repos if REPO omitted
|
||||
## make fix-consistency-remote — smart pull+fix all repos that need it
|
||||
## make fix-consistency-remote REPO=slug — pull+fix one repo
|
||||
fix-consistency-remote:
|
||||
$(UV) run python scripts/consistency_check.py \
|
||||
$(if $(REPO),--repo "$(REPO)",--all) \
|
||||
--remote \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(NO_WRITEBACK),--no-writeback,); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Infer repo slug from git remote URL and check: make check-consistency-here [REPO_PATH=/path/to/repo]
|
||||
## Omit REPO_PATH to use the Python script's CWD (i.e. pass an empty --here flag).
|
||||
check-consistency-here:
|
||||
$(UV) run python scripts/consistency_check.py \
|
||||
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Infer repo slug from git remote URL and fix: make fix-consistency-here [REPO_PATH=/path/to/repo]
|
||||
fix-consistency-here:
|
||||
$(UV) run python scripts/consistency_check.py \
|
||||
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
|
||||
--fix \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check all registered repos for ADR-001 consistency
|
||||
check-consistency-all:
|
||||
$(UV) run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check and auto-fix all registered repos
|
||||
fix-consistency-all:
|
||||
$(UV) run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Cancel open tasks belonging to completed/archived workstreams.
|
||||
## Safe to run at any time; also suitable for a daily cron job.
|
||||
## Cron example: 0 3 * * * cd ~/state-hub && make cleanup-stale
|
||||
cleanup-stale:
|
||||
$(UV) run python scripts/cleanup_stale_tasks.py
|
||||
|
||||
## Install custodian post-commit sync hook into one repo: make install-hooks REPO=marki-docx
|
||||
install-hooks:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make install-hooks REPO=<slug>"; exit 1)
|
||||
bash scripts/install_hooks.sh --repo "$(REPO)"
|
||||
|
||||
## Install custodian post-commit sync hook into all active registered repos
|
||||
install-hooks-all:
|
||||
bash scripts/install_hooks.sh --all
|
||||
|
||||
## Remove custodian post-commit sync hook from one repo: make remove-hooks REPO=marki-docx
|
||||
remove-hooks:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make remove-hooks REPO=<slug>"; exit 1)
|
||||
bash scripts/install_hooks.sh --repo "$(REPO)" --remove
|
||||
|
||||
## Compare Gitea coulomb org repos against state-hub registered repos
|
||||
## Requires GITEA_TOKEN in env or .env: make gitea-inventory GITEA_TOKEN=<token>
|
||||
gitea-inventory:
|
||||
$(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json)
|
||||
|
||||
## Register/update one repo from .repo-classification.yaml:
|
||||
## make register-from-classification REPO=state-hub
|
||||
## make register-from-classification PATH=/path/to/repo
|
||||
## Optional: DRY_RUN=1
|
||||
register-from-classification:
|
||||
@test -n "$(REPO)" -o -n "$(PATH)" || (echo "ERROR: REPO or PATH is required."; exit 1)
|
||||
$(UV) run python scripts/register_from_classification.py \
|
||||
$(if $(PATH),--repo-path "$(PATH)",--slug "$(REPO)") \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
## Bulk register/update all active repos with accessible local paths
|
||||
register-from-classification-all:
|
||||
$(UV) run python scripts/register_from_classification.py --bulk \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
clean:
|
||||
$(COMPOSE) down -v
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
apiVersion: v2
|
||||
name: state-hub
|
||||
description: State Hub API service for private Railiance operation
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "main-c103cbf"
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# Production values for the State Hub Railiance chart handoff.
|
||||
# Non-secret values only. DATABASE_URL comes from the Secret `state-hub-env`.
|
||||
|
||||
namespace:
|
||||
create: false
|
||||
|
||||
image:
|
||||
repository: forgejo.coulomb.social/coulomb/state-hub
|
||||
tag: "main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1"
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
# MCP layer on central (CUST-WP-0067-T08). Enabled together with the image tag
|
||||
# above: MCP_HOST landed in main-6c1262e, and enabling this on an older image
|
||||
# would deploy a pod that binds loopback and never becomes reachable.
|
||||
# ClusterIP only — no ingress, same reason the API's is disabled.
|
||||
mcp:
|
||||
enabled: true
|
||||
resources:
|
||||
requests:
|
||||
# 2026-09-10: 1,078 observed 5-minute samples, p99 2.35m, peak 30.45m.
|
||||
# Preserve the 500m limit for bursts; free 40m for the API rollout surge.
|
||||
cpu: 10m
|
||||
|
||||
config:
|
||||
# This deployment is the authoritative hub. Callers verify this rather than
|
||||
# trusting that whatever answered on a port is central (CUST-WP-0067-T03).
|
||||
instanceRole: primary
|
||||
instanceLabel: railiance01
|
||||
sbomNexusUrl: "http://sbom-nexus.sbom-nexus.svc.cluster.local:8010"
|
||||
# Reversible T04 read cutover; set back to `legacy` to roll back.
|
||||
sbomNexusReadMode: nexus
|
||||
# Reversible T04 write cutover; set back to `legacy` to roll back future writes.
|
||||
sbomNexusWriteMode: nexus
|
||||
|
||||
resources:
|
||||
# Restored to the chart's 250m on 2026-09-21. The earlier 100m was a workaround
|
||||
# for a node with <250m unallocated; ~830m of Knative/Kourier requests were
|
||||
# released that day (the-custodian docs/kubernetes-change-gate-decision.md).
|
||||
# State Hub idles near 5m but bursts past 400m under concurrent agent load.
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 2Gi
|
||||
|
||||
sweep:
|
||||
# RMGR-WP-0005-T11: disabled while railiance01 checkouts still target the
|
||||
# stale gitea-remote lineage. Re-enable only after the governed remote
|
||||
# reconciliation and registrar preflight are complete.
|
||||
enabled: false
|
||||
# Present in the live release but previously missing here, so deploying from
|
||||
# this file alone silently dropped it. Inert while enabled is false.
|
||||
hostname: 239.62.205.92.host.secureserver.net
|
||||
hostPath: /home/tegwick
|
||||
sshHostPath: /home/tegwick/.ssh
|
||||
|
||||
# Forge read credential (STATE-WP-0084-T02). Coordinates only — the token lives
|
||||
# in OpenBao at platform/workloads/state-hub/forge-derivation and reaches the
|
||||
# pod through Kubernetes auth, never through this file or a Secret.
|
||||
forgeRead:
|
||||
enabled: true
|
||||
|
||||
# RPF-WP-0035-T04: dedicated ESO signing key, API-only explicit reference.
|
||||
renamePreflight:
|
||||
enabled: true
|
||||
secretName: state-hub-rename-preflight
|
||||
|
|
@ -0,0 +1,422 @@
|
|||
---
|
||||
id: STATE-WP-0095
|
||||
type: workplan
|
||||
title: "Preserve exact closed historical source bindings in Forge receiving"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: active
|
||||
owner: codex
|
||||
created: "2026-10-04"
|
||||
updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058"
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Normal Forge receiving must preserve the seven bound Activity legacy source
|
||||
views and the Custodian retired ADHOC source without new identities, source
|
||||
rewrites, task movement, deletion, relabeling or unretirement. This implements
|
||||
receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns
|
||||
cross-owner coordination; deployment and replay remain explicit owner returns.
|
||||
|
||||
## Verify source bindings and shared views
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7"
|
||||
```
|
||||
|
||||
Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound
|
||||
closed tasks. The archived custodian-WP-0003 parent and three split_from views
|
||||
share one existing parent UUID; every repeated task binding/content agrees.
|
||||
Custodian ADHOC-2026-08-25-retired retains parent
|
||||
1c53d3db-6633-5e2d-987f-10706d73be9e and task
|
||||
d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized.
|
||||
|
||||
## Implement preservation receiving and global collision refusal
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822"
|
||||
```
|
||||
|
||||
Prepared exact source digest manifest, existing-parent/task verification,
|
||||
immutable receiving and UUID-based historical diff. Ordinary canonical sources
|
||||
continue deriving their identities. Missing/changed/symlinked source, absent
|
||||
bound rows or nonterminal hub histories refuse. Check task UUID holders globally,
|
||||
including retired and acknowledged-stale parent workplans, before INSERT.
|
||||
|
||||
Offline preview tests and snapshot proof are evidence of source behavior only;
|
||||
finish after applying reviewed source and its focused tests to the owning repo.
|
||||
|
||||
## Deliver receiving through normal deployment and verify owner return
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T03
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T02]
|
||||
state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a"
|
||||
decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca"
|
||||
```
|
||||
|
||||
No direct live code patch. This task is a deployment return, not policy authority
|
||||
or permission to resolve identity decisions. Exact receiving fixture acceptance
|
||||
and global collision rejection must hold in the delivered version.
|
||||
|
||||
## Verify normal historical replay preserves existing identities
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T04
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03]
|
||||
state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4"
|
||||
```
|
||||
|
||||
Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired
|
||||
parent/task unchanged; no historical row creation, movement, deletion,
|
||||
relabeling or unretirement. Unrelated stale projections remain separate decisions.
|
||||
|
||||
2026-10-05: Source exact-set retirement support now exists. Repository projection
|
||||
reconcile accepts `expected_retirement_uuids`; that set must equal the stale
|
||||
projections, and any task or dependency edge on a stale row refuses the call.
|
||||
Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted
|
||||
review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three
|
||||
empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`.
|
||||
That acceptance is not a live retirement and does not resolve
|
||||
`91b7c633-a66f-5d43-9f4b-6565aefb9356`.
|
||||
|
||||
## Offline evidence, 2026-10-04
|
||||
|
||||
Prepared receiver suite: Forge projection regression plus historical preservation,
|
||||
missing/changed source, terminality, immutable UUID diff, and global holder query.
|
||||
Real-source offline snapshot proof preserves four Activity parents/64 tasks and
|
||||
one Custodian parent/task without mutations; no API writes or deployment occurred.
|
||||
Exact original bindings are maintained in api/services/historical_projection.py.
|
||||
|
||||
2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner
|
||||
cases passed; the independent offline current-source/current-hub-snapshot proof
|
||||
retained five existing parents and 65 task UUIDs, no mutations, clean historical
|
||||
diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and
|
||||
normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json.
|
||||
|
||||
|
||||
## Verified receiving rollout and remaining replay gate, 2026-10-04
|
||||
|
||||
T03 is done on actual founder approval of proposal
|
||||
STATE-WP-0095-T03-historical-receiving revision 1, SHA256
|
||||
`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`,
|
||||
approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`.
|
||||
The normal owner Helm path delivered `main-5dd3df9`, commit
|
||||
`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready,
|
||||
primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source
|
||||
hashes match. Rollback remains revision 71; no receipt-table downgrade is needed.
|
||||
Actual historical preservation and global task-holder refusal were verified in
|
||||
the delivered version. Durable receipt:
|
||||
[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json).
|
||||
|
||||
Custodian immutable replay applied while preserving the retired historical
|
||||
parent/task UUIDs. Activity immutable replay retained all four historical parent
|
||||
and 64 task UUIDs and historical row contents, but refused transactionally on
|
||||
three unrelated stale projections: custodian-wp-0003 (archived),
|
||||
custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement
|
||||
was issued and that refusal is not represented as normal replay success.
|
||||
|
||||
Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own
|
||||
normal sync succeeded; registrar admission does not authorize retiring Activity
|
||||
projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting
|
||||
Activity Core's precise reviewed disposition of those three stale rows. No
|
||||
rename, identity migration, new paid native trial or further live action is
|
||||
included in this source status update.
|
||||
|
||||
## Activity Core return, 2026-10-06
|
||||
|
||||
Activity Core returned the exact disposition (message
|
||||
`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit
|
||||
`941fac0`). `expected_retirement_uuids`:
|
||||
`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003),
|
||||
`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a),
|
||||
`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero
|
||||
tasks and zero dependency edges. None is preserved parent
|
||||
`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the
|
||||
set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired.
|
||||
T04 now waits on decision `91b7c633` and a reviewed live rollout. After that,
|
||||
Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03.
|
||||
|
||||
## Retirement rollout and T04 verification, 2026-10-06
|
||||
|
||||
The founder resolved decision `91b7c633` (retire exactly the three projections)
|
||||
and approved the rollout. The founder ran the normal Helm upgrade to
|
||||
`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP
|
||||
are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is
|
||||
unchanged; no migrations. The activity-core reconcile at commit
|
||||
`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set
|
||||
to the three UUIDs applied: 0 created, 3 retired (tombstoned with an
|
||||
`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks
|
||||
cancelled. All 7 bound Activity source files still match their digests. The four
|
||||
historical parents and 64 task UUIDs are present under their bound parents with
|
||||
unchanged fields. The Custodian retired parent and task are unchanged. Activity
|
||||
had 54 workplans and 288 tasks before and after. Evidence:
|
||||
[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json).
|
||||
Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and
|
||||
ACTIVITY-WP-0045-T02 remain with their owners.
|
||||
|
||||
|
||||
## Cluster exact historical receiving successor, 2026-10-17
|
||||
|
||||
This scoped extension retains the completed Activity/Custodian outcomes above.
|
||||
Three frozen cluster archives contain three existing parent UUIDs and 15 existing
|
||||
closed task UUIDs. Extend the existing exact historical receiving path before
|
||||
registrar admission; never derive replacement identities or modify those
|
||||
sources. Cross-owner observation attaches to existing SHR-WP-0001, with
|
||||
RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly.
|
||||
The local derived case is waiting; no live worker injection is authorized.
|
||||
|
||||
## Prepare exact cluster source preservation and refusal cases
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b"
|
||||
```
|
||||
|
||||
Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager.
|
||||
Candidate receiving tests pass 26: existing rows remain entirely unchanged;
|
||||
missing/foreign/nonterminal rows and unknown, changed, missing or symlinked
|
||||
source refuse. Read-only public metadata confirms all 18 existing rows have the
|
||||
expected ownership and terminal status. These are source and metadata checks,
|
||||
not delivered receiving or a committed native trial.
|
||||
|
||||
## Deliver reviewed cluster receiving successor before registrar admission
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T06
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T05]
|
||||
needs_human: false
|
||||
intervention_note: "Exact revision 4 approved and verified; no pending rollout intervention."
|
||||
state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4"
|
||||
```
|
||||
|
||||
Completed 2026-10-17 on the founder's approval of exact revision 4 of stable
|
||||
proposal STATE-WP-0095-T06-cluster-frozen-receiving-r1 (decision
|
||||
e3798419-8aa9-5425-8c08-20df6bd82a19; approved digest
|
||||
sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32).
|
||||
The ordinary owner Helm path delivered revision 76, runtime source 51bc70d and
|
||||
exact image manifest sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015.
|
||||
API/MCP Ready and primary/railiance01 health report expected schema
|
||||
c4d5e6f7a8b9; delivered historical module sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2 matches.
|
||||
All three parent and 15 task full DB row hashes/columns/UUIDs remained unchanged.
|
||||
Five actual native probes passed: preserve, actual foreign-parent holder move,
|
||||
nonterminal task, nonterminal parent and missing task. Every probe rolled back;
|
||||
a fresh transaction confirmed complete row restoration. The foreign-holder
|
||||
case precisely verified historical binding refusal, not the separate global
|
||||
UUID-holder creation branch. No committed probe writes were made.
|
||||
|
||||
Evidence: [verified receiving rollout](../docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json).
|
||||
This actual receiving return satisfies the prerequisite of RMGR-WP-0019-T05.
|
||||
Normal cluster publication and its immutable-source/row return remain T07;
|
||||
source readiness and this deployment do not count that later acceptance twice.
|
||||
The additive inbox migration is retained; Helm rollback does not downgrade
|
||||
schema or stop an active migration Job. Original failures and exact-UID recovery
|
||||
limits remain in the referenced approved packet. No TLS recovery/native private
|
||||
admission or new autonomy/cost grant is inferred.
|
||||
|
||||
## Verify ordinary cluster receiving and immutable historical rows
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T07
|
||||
status: wait
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10]
|
||||
state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d"
|
||||
```
|
||||
|
||||
Capture normal cluster owner publication/receiving receipts and compare entire
|
||||
existing row snapshots and exact source bytes. No creation, movement, relabel,
|
||||
unretirement or retirement is authorized for these histories. A rollback first
|
||||
fences cluster sync and restores registrar refusal before removing receiving
|
||||
support; it never rewrites archived IDs or UUIDs. This work does not establish
|
||||
original TLS recovery or owner-native admission.
|
||||
|
||||
## Fix policy-compatible Secret-template server dry-run preparation
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T08
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858"
|
||||
```
|
||||
|
||||
Prepared the narrow Makefile successor after the existing readiness command
|
||||
refused its first Secret-template phase. Ordinary client-side apply adds the
|
||||
last-applied annotation, correctly denied by the existing Platform
|
||||
reject-secret-last-applied policy. Only the two Secret-template server
|
||||
dry-run requests now use server-side apply with dry-run; no force-conflicts,
|
||||
Secret value change, policy relaxation or actual Secret write is introduced.
|
||||
Command-fixture tests verify both requests and preserve other dry-run phases.
|
||||
Evidence and original refusal remain in the Custodian cluster historical
|
||||
publication packet, revisions/r2 and r3-readiness. This is completed source
|
||||
preparation; T06 separately records the verified actual rollout. The runtime image
|
||||
continues to be main-51bc70d; Makefile/workplan changes do not create a new
|
||||
application image or native acceptance.
|
||||
|
||||
|
||||
T08 source repair refinement, 2026-10-17: the required normal Make render
|
||||
correctly refused a pre-existing generated chart classification copy at v1.0
|
||||
after authoritative canon reached v1.1. Regenerated only through the existing
|
||||
scripts/sync_classification_allowed.py, preserving canon and strict check.
|
||||
The exact source diff is version 1.0→1.1 plus publication/history/participation.
|
||||
Normal Make rendering and the focused classification check passed in an
|
||||
isolated owner-layout copy. The original refusal is retained in the Custodian
|
||||
owner-followthrough-r1 validation receipt. This is source repair only: no live
|
||||
ConfigMap apply, new runtime image, policy relaxation or T07 completion.
|
||||
|
||||
|
||||
T07 actual ordinary-publication return, 2026-10-17: cluster owner deep consistency
|
||||
and normal fast projection completed, exact archived source bytes and all 15
|
||||
historical task full-row hashes remained unchanged. The later full-row check
|
||||
FAILED for exactly the three parent rows. The earlier approved T06 rollout had
|
||||
preserved all 18 rows at its verification stage; that success is not the later
|
||||
whole-pipeline acceptance. The original snapshots retain hashes only, so exact
|
||||
changed columns and original field preimages are not established. No guessed
|
||||
DB repair or claim that all 18 original row hashes are restored is authorized.
|
||||
T07 and RMGR-WP-0019-T06 remain waiting pending T09/T10 and a reviewed return.
|
||||
|
||||
## Guard immutable historical index bindings in CLI and receiving API
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T09
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T06]
|
||||
state_hub_task_id: "8be8f13b-6352-5295-9755-de6fbfb13593"
|
||||
```
|
||||
|
||||
Prepare and publish the narrowly scoped ordinary-binding repair for the three
|
||||
existing task-bound Cluster registry entries. The CLI verifies exact frozen
|
||||
source metadata/bytes and omits those bindings, including on old receivers.
|
||||
The API independently validates the complete batch before any mutation, checks
|
||||
actual repo ownership and the existing terminal parent/task closure, and
|
||||
preserves valid frozen rows without field or timestamp assignments. Invalid
|
||||
metadata, missing/foreign/nonterminal rows refuse the batch. Unknown ordinary
|
||||
bindings retain existing behavior; historical IDs, UUIDs and source bytes stay
|
||||
unchanged. Isolated regression tests and normal owner rendering are required.
|
||||
Reviewed source preparation and canonical regression proof completed on
|
||||
2026-10-17: 102 isolated tests pass and normal Make render/classification
|
||||
check passes (eight objects, three unchanged delivered runtime images).
|
||||
This completion record accompanies the same source publication/projection;
|
||||
source preparation does not establish delivered runtime or T07 acceptance.
|
||||
Central check/fix engines also refuse the complete repair batch if any fixable
|
||||
context touches an exact registered parent/task UUID or frozen source path.
|
||||
This runs before index bindings and before reconcile/push/individual fixes;
|
||||
ordinary earlier fixes cannot partially execute before a later frozen refusal.
|
||||
|
||||
## Deliver and verify the ordinary-binding guard successor
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T10
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T09]
|
||||
needs_human: false
|
||||
intervention_note: "Exact revision 2 approved and verified; no pending rollout intervention."
|
||||
state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824"
|
||||
```
|
||||
|
||||
After source publication, resolve normal CI status and immutable image identity,
|
||||
verify delivered guard source closure, prepare exact chart/reuse-values dry-run,
|
||||
full closure/headroom/schema baseline and conditional recovery. Retain additive
|
||||
migration and active-Job cancellation limitations; no Helm schema downgrade or
|
||||
bounded-outage claim. Obtain scoped approval before live rollout. Then verify
|
||||
new full-row baseline preservation and actual rollback-only refusal probes,
|
||||
and only then return to T07 ordinary-pipeline acceptance. This prevents future
|
||||
binding mutation; it does not reconstruct or restore original parent hashes.
|
||||
No production changes, new autonomy or credential authority are granted here.
|
||||
|
||||
|
||||
T09 source repair refinement, 2026-10-17: the attempted current-client regression
|
||||
actually loaded an older packaged consistency checker from the owner virtualenv,
|
||||
not the reviewed canonical source. That trial again failed parent-row hash
|
||||
preservation; it is retained separately and does not count as R5 client proof.
|
||||
Normal C-23 also legitimately normalized WP18 ready to active and created a
|
||||
source commit, but the generic writeback commit included an unrelated staged
|
||||
brief. A narrow successor scopes git add and git commit --only to the actual
|
||||
writeback file, preserving unrelated index/worktree state. Real disposable Git
|
||||
regression tests cover pre-existing staged changes, hooks, no-op and path names.
|
||||
Verify the actually loaded CLI checker hash after ordinary owner package refresh
|
||||
before any retry; capture legitimate source HEAD transitions rather than assume
|
||||
a static revision. T07 and T10 remain wait. No original parent preimage recovery,
|
||||
new runtime delivery or broader authority follows from this source repair.
|
||||
|
||||
|
||||
T07 current-baseline client return, 2026-10-17: after the owner refreshed the
|
||||
installed package and verified the actually loaded checker matched the reviewed
|
||||
R6 source, ordinary Cluster deep consistency and fast projection passed.
|
||||
Bindings synced 20/20, omitting the three frozen histories; all three parent
|
||||
and 15 task CURRENT full-row hashes remained unchanged across
|
||||
before-verified-client.json / after-verified-client.json. Unrelated staged brief
|
||||
state was preserved. This proves the corrected installed client stage against
|
||||
its fresh current baseline; earlier parent-hash failures remain retained
|
||||
historical exceptions, not restored preimages or backfilled successes.
|
||||
|
||||
T10 remains wait/needs_human for exact proposal
|
||||
STATE-WP-0095-T10-frozen-bindings-guard-r1, now revision 2, metadata
|
||||
sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e,
|
||||
pending decision df703266-6719-4c04-ae1b-ba9afd3bca1c. The inspected candidate
|
||||
image is main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1,
|
||||
from runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3. Image-only
|
||||
server dry-run passed eight identities with only three image-field changes.
|
||||
The exact proposal and recovery limits are retained in the Custodian dated
|
||||
2026-10-17 guard-successor-review-r2 packet. No new API runtime is approved or
|
||||
delivered by this source-only note; its later workplan commit does not change
|
||||
the image's source pin. T09 stays done, T07/T10 stay wait pending reviewed API
|
||||
delivery and explicit successor/current-baseline acceptance disposition.
|
||||
|
||||
|
||||
T10 proposal revision history: revision 1 metadata
|
||||
sha256:81d365926fc585a186955d8c98ee83501fd3cec12135b2256d71b46a75097932
|
||||
and its original helper remain immutable local evidence, unapproved and
|
||||
unexecuted. Revision 2 of the same stable proposal changes only the native
|
||||
probe's EXPECTED hash metadata into module/sha256 records for normal publication
|
||||
scanning; probe behavior, exact image, recovery and acceptance scope remain
|
||||
unchanged. The same decision df703266-6719-4c04-ae1b-ba9afd3bca1c remains open
|
||||
and pending. This source-only amendment grants no runtime authority or new
|
||||
image/code change; T07/T10 remain wait and T09 remains done.
|
||||
|
||||
|
||||
T10 verified receiving return, 2026-10-17: the founder approved and the owner
|
||||
executed exact revision 2 of STATE-WP-0095-T10-frozen-bindings-guard-r1,
|
||||
sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e,
|
||||
under decision df703266-6719-4c04-ae1b-ba9afd3bca1c. Helm revision 77 delivered
|
||||
runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3 and image
|
||||
main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1.
|
||||
API/MCP are Ready on expected schema c4d5e6f7a8b9. Eight actual native cases
|
||||
passed with zero probe commits. All three parent and 15 task CURRENT full-row
|
||||
hashes remained unchanged after delivery/probes and the actual normal Cluster
|
||||
pipeline (20 ordinary bindings, three frozen bindings omitted by the verified
|
||||
loaded checker). All three archived sources remained byte-identical. Exact
|
||||
owned Job/pod success was observed, then their absence and removal of owned
|
||||
chart staging were verified; no recovery was needed.
|
||||
|
||||
The verified return is the Custodian dated 2026-10-17
|
||||
guard-successor-execution-r2/verified-receiving-return.json,
|
||||
sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a.
|
||||
The original three-parent hash failures remain the founder-accepted historical
|
||||
exception: unavailable original preimages were not guessed/restored, and failed
|
||||
outcomes were not backfilled. The 15 original task rows and archived bytes stay
|
||||
preserved. Earlier pending review notes are retained as proposal history; T10
|
||||
is now done and needs_human false. T07 and this parent remain open until the
|
||||
actual RMGR-WP-0019-T06 owner closure is published and returned. No native
|
||||
security-target gate or broader autonomy is established by this infrastructure
|
||||
return. Source appVersion and both maintained image pins now declare this same
|
||||
verified image; the approved frozen rollout archive remains unchanged.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"schema": "state95.final-owner-closure.v1",
|
||||
"base_revision": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32",
|
||||
"files": [
|
||||
{
|
||||
"path": "workplans/STATE-WP-0095-historical-source-receiving.md",
|
||||
"base_sha256": "bcde1a6502dfad71586dc38b2f681723dcb310c22f66f052b305ded89bf9b00d",
|
||||
"candidate_sha256": "31bea1d5b54f9f6e5bc01d11d271c3a86ac76389350b467ba568b9dc93e41c61"
|
||||
}
|
||||
],
|
||||
"patch_sha256": "sha256:cb29c018ba5ebe189e3bf40b3c4889484ed622f6f538e5671b7f80ee200c3e08",
|
||||
"new_UUIDs": false,
|
||||
"runtime_changed": false,
|
||||
"parent": "finished",
|
||||
"all10tasks": "done"
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
{
|
||||
"ok": true,
|
||||
"status": "applied",
|
||||
"repo_slug": "state-hub",
|
||||
"commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a",
|
||||
"requests": 2,
|
||||
"elapsed_ms": 15534.8,
|
||||
"git": {
|
||||
"upstream": "origin/main",
|
||||
"behind": 0,
|
||||
"ahead": 0
|
||||
},
|
||||
"identifiers": {
|
||||
"schema": "repo-manager.identifier-assignment.v1",
|
||||
"ok": true,
|
||||
"executed": true,
|
||||
"repo": "state-hub",
|
||||
"namespace": "helixforge",
|
||||
"assignments": [],
|
||||
"historical_invalid_identifiers": [],
|
||||
"historical_identity_collisions": [],
|
||||
"files_changed": []
|
||||
},
|
||||
"receipt": {
|
||||
"schema": "state-hub.repository-projection-reconcile.v1",
|
||||
"instance_role": "primary",
|
||||
"instance_label": "railiance01",
|
||||
"expected_commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a",
|
||||
"derived_commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a",
|
||||
"outcome": {
|
||||
"schema": "state-hub.projection-reset.v1",
|
||||
"repo_slug": "state-hub",
|
||||
"commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a",
|
||||
"status": "applied",
|
||||
"counts": {
|
||||
"created": 0,
|
||||
"updated": 70,
|
||||
"retired": 0,
|
||||
"refused": 0,
|
||||
"released": 0,
|
||||
"created_tasks": 0,
|
||||
"updated_tasks": 2,
|
||||
"cancelled_tasks": 0,
|
||||
"created_edges": 0,
|
||||
"removed_edges": 3,
|
||||
"unresolved_edges": 0,
|
||||
"external_edges": 6
|
||||
},
|
||||
"created": [],
|
||||
"updated": [
|
||||
"CUST-WP-0003",
|
||||
"CUST-WP-0011",
|
||||
"CUST-WP-0012",
|
||||
"CUST-WP-0038",
|
||||
"CUST-WP-0042",
|
||||
"STATE-WP-0043",
|
||||
"STATE-WP-0044",
|
||||
"STATE-WP-0045",
|
||||
"STATE-WP-0046",
|
||||
"STATE-WP-0047",
|
||||
"STATE-WP-0048",
|
||||
"STATE-WP-0049",
|
||||
"STATE-WP-0050",
|
||||
"STATE-WP-0051",
|
||||
"STATE-WP-0052",
|
||||
"STATE-WP-0053",
|
||||
"STATE-WP-0054",
|
||||
"STATE-WP-0055",
|
||||
"STATE-WP-0056",
|
||||
"STATE-WP-0057",
|
||||
"STATE-WP-0058",
|
||||
"STATE-WP-0059",
|
||||
"STATE-WP-0060",
|
||||
"STATE-WP-0061",
|
||||
"STATE-WP-0062",
|
||||
"STATE-WP-0063",
|
||||
"STATE-WP-0064",
|
||||
"STATE-WP-0065",
|
||||
"STATE-WP-0066",
|
||||
"STATE-WP-0067",
|
||||
"STATE-WP-0068",
|
||||
"STATE-WP-0069",
|
||||
"STATE-WP-0070",
|
||||
"STATE-WP-0071",
|
||||
"STATE-WP-0072",
|
||||
"STATE-WP-0073",
|
||||
"STATE-WP-0074",
|
||||
"STATE-WP-0075",
|
||||
"STATE-WP-0076",
|
||||
"STATE-WP-0077",
|
||||
"STATE-WP-0078",
|
||||
"STATE-WP-0079",
|
||||
"STATE-WP-0080",
|
||||
"STATE-WP-0081",
|
||||
"STATE-WP-0082",
|
||||
"STATE-WP-0083",
|
||||
"STATE-WP-0084",
|
||||
"STATE-WP-0085",
|
||||
"STATE-WP-0086",
|
||||
"STATE-WP-0087",
|
||||
"STATE-WP-0088",
|
||||
"STATE-WP-0089",
|
||||
"STATE-WP-0090",
|
||||
"STATE-WP-0091",
|
||||
"STATE-WP-0092",
|
||||
"STATE-WP-0093",
|
||||
"STATE-WP-0094",
|
||||
"STATE-WP-0095",
|
||||
"STATE-WP-0096",
|
||||
"STATE-WP-0097",
|
||||
"STATE-WP-0098",
|
||||
"STATE-WP-ADHOC-2026-06-04",
|
||||
"STATE-WP-ADHOC-2026-07-01",
|
||||
"STATE-WP-ADHOC-2026-08-08",
|
||||
"STATE-WP-ADHOC-2026-08-23",
|
||||
"STATE-WP-ADHOC-2026-08-30",
|
||||
"STATE-WP-ADHOC-2026-09-04",
|
||||
"STATE-WP-ADHOC-2026-09-22",
|
||||
"STATE-WP-ADHOC-2026-10-06",
|
||||
"STATE-WP-ADHOC-2026-10-13"
|
||||
],
|
||||
"retired": [],
|
||||
"released": [],
|
||||
"created_tasks": [],
|
||||
"updated_tasks": [
|
||||
"STATE-WP-0095-T07",
|
||||
"STATE-WP-0095-T10"
|
||||
],
|
||||
"cancelled_tasks": [],
|
||||
"created_edges": [],
|
||||
"removed_edges": [
|
||||
{
|
||||
"id": "cc2867c3-4cb1-4b63-8c0d-02529a361a15",
|
||||
"from_workplan_id": "79579572-8120-5d3b-aca5-dd4aff60d058",
|
||||
"from_task_id": "cfd1ea99-e75b-542e-a434-acefb62f3c7d",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "4d1675b6-f49f-5b01-8d76-bbf2030d1b34",
|
||||
"relationship_type": "starts_after"
|
||||
},
|
||||
{
|
||||
"id": "e74ef85a-7884-4248-9496-7f1c3150313d",
|
||||
"from_workplan_id": "79579572-8120-5d3b-aca5-dd4aff60d058",
|
||||
"from_task_id": "cfd1ea99-e75b-542e-a434-acefb62f3c7d",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4",
|
||||
"relationship_type": "starts_after"
|
||||
},
|
||||
{
|
||||
"id": "713e6db5-7574-4ade-a750-84bdde02f80a",
|
||||
"from_workplan_id": "79579572-8120-5d3b-aca5-dd4aff60d058",
|
||||
"from_task_id": "cfd1ea99-e75b-542e-a434-acefb62f3c7d",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "88585c69-d9b9-5e1f-a256-6416ff71e824",
|
||||
"relationship_type": "starts_after"
|
||||
}
|
||||
],
|
||||
"unresolved_edges": [],
|
||||
"external_edges": [
|
||||
{
|
||||
"workplan": "STATE-WP-0098",
|
||||
"from": "STATE-WP-0098-T04",
|
||||
"target": "HUB-WP-0011-T01",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "0c68970e-295b-57fc-ac70-e472ee3a7bc4",
|
||||
"from_task_id": "077797c8-289b-55ec-9597-28f223891b02",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "588e4b58-4694-513c-8e7a-1fab38fb6ce3"
|
||||
},
|
||||
{
|
||||
"workplan": "CUST-WP-0038",
|
||||
"from": "CUST-WP-0038",
|
||||
"target": "CUST-WP-0011",
|
||||
"relationship_type": "blocks",
|
||||
"from_workplan_id": "ccb87fa4-6381-5f22-b097-6e026f56a9c1",
|
||||
"from_task_id": null,
|
||||
"to_workplan_id": "967baafb-d92d-405a-ba0b-0d00d37c4940",
|
||||
"to_task_id": null
|
||||
},
|
||||
{
|
||||
"workplan": "STATE-WP-0079",
|
||||
"from": "STATE-WP-0079-T04",
|
||||
"target": "HUB-WP-0011-T03",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854",
|
||||
"from_task_id": "3a454438-1955-547f-a491-8eab7cd25710",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "61c727a5-f4b1-54c0-b634-7d71a5416496"
|
||||
},
|
||||
{
|
||||
"workplan": "STATE-WP-0079",
|
||||
"from": "STATE-WP-0079-T04",
|
||||
"target": "HUB-WP-0006-T06",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854",
|
||||
"from_task_id": "3a454438-1955-547f-a491-8eab7cd25710",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "7c8225f7-03dc-57b0-a668-c3d781bd828a"
|
||||
},
|
||||
{
|
||||
"workplan": "STATE-WP-0079",
|
||||
"from": "STATE-WP-0079-T06",
|
||||
"target": "STATE-WP-0079-T04",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854",
|
||||
"from_task_id": "9230c99b-af58-5794-9291-452e619a30fa",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "3a454438-1955-547f-a491-8eab7cd25710"
|
||||
},
|
||||
{
|
||||
"workplan": "STATE-WP-0079",
|
||||
"from": "STATE-WP-0079-T06",
|
||||
"target": "STATE-WP-0079-T05",
|
||||
"relationship_type": "starts_after",
|
||||
"from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854",
|
||||
"from_task_id": "9230c99b-af58-5794-9291-452e619a30fa",
|
||||
"to_workplan_id": null,
|
||||
"to_task_id": "f6cf8c01-eb59-5ca8-a541-23928d4a000c"
|
||||
}
|
||||
],
|
||||
"refused": [],
|
||||
"notes": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"schema": "state95.final-closure-publication.v1",
|
||||
"status": "verified",
|
||||
"source_revision": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a",
|
||||
"parent_status": "finished",
|
||||
"task_count": 10,
|
||||
"all_tasks_done": true,
|
||||
"all_UUIDs_preserved": true,
|
||||
"unrelated_dirty_and_index_preserved": true,
|
||||
"native_security_target_gates_closed": 0,
|
||||
"runtime_source": "c103cbf3c8bed5dba87a03e320595ddded9301a3",
|
||||
"bound_receipts": [
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/verified-registrar-return.json",
|
||||
"sha256": "sha256:6bf7fc2ee86688d6e15d584c9157bacf4fff6f87b887f28aa7ed8292df28d35e"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/manifest.json",
|
||||
"sha256": "sha256:634c816d73164a9d256b3e92205869d688dfe865edfd33fbc1be4052bb0f8795"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/source-commit-result.json",
|
||||
"sha256": "sha256:b1587c9e24befd1b4e1a4137c50a717c7c0f87d120105e8275da23d267042e8e"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/normal-sync-result.json",
|
||||
"sha256": "sha256:bd72497f8fbe6f3574b35308d788be88456f5d9905d320d14e9659a560395291"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/source-publication-readback.json",
|
||||
"sha256": "sha256:83878a62d3c6fdfc5f1568bc6cad16c7c14d2d257ecd59471473cb53da650836"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepared exact-ten-path source publisher; requires final reviewed overlay. No deploy/sync."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
OWNER = Path('/home/worsch/state-hub')
|
||||
SCANNER = ['/usr/bin/python3', '/home/worsch/repo-manager/src/repo_manager/secret_scan.py',
|
||||
'--path', str(OWNER), '--binary', '/home/worsch/.local/lib/repo-manager/gitleaks/8.30.1/gitleaks']
|
||||
|
||||
class Refusal(RuntimeError):
|
||||
pass
|
||||
|
||||
def sha(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
def git(owner, *args, check=True):
|
||||
p = subprocess.run(['git', '-C', str(owner), *args], capture_output=True, timeout=150)
|
||||
if check and p.returncode:
|
||||
raise Refusal('git_' + args[0] + '_refused_' + str(p.returncode))
|
||||
return p
|
||||
|
||||
def index_entries(owner):
|
||||
# Stage bytes remain private to this process. Receipts expose hashes only.
|
||||
return dict((entry.split(b'\t', 1)[1].decode(), entry.split(b'\t', 1)[0])
|
||||
for entry in git(owner, 'ls-files', '--stage', '-z').stdout.split(b'\0') if entry)
|
||||
|
||||
def dirty_files(owner):
|
||||
tracked = git(owner, 'diff', '--name-only', 'HEAD', '-z').stdout.split(b'\0')
|
||||
untracked = git(owner, 'ls-files', '--others', '--exclude-standard', '-z').stdout.split(b'\0')
|
||||
return sorted({p.decode() for p in tracked + untracked if p})
|
||||
|
||||
def preserved_state(owner, owned):
|
||||
indices = {p: sha(v) for p, v in index_entries(owner).items() if p not in owned}
|
||||
worktree = {}
|
||||
for p in dirty_files(owner):
|
||||
if p in owned:
|
||||
continue
|
||||
file = owner / p
|
||||
if file.is_symlink():
|
||||
raise Refusal('unrelated_symlink_requires_review')
|
||||
worktree[p] = sha(file.read_bytes()) if file.exists() else None
|
||||
return {'index_entries': indices, 'dirty_worktree': worktree}
|
||||
|
||||
def validate(owner, expected):
|
||||
if git(owner, 'rev-parse', 'HEAD').stdout.decode().strip() != expected['base_revision']:
|
||||
raise Refusal('source_base_drift')
|
||||
files = expected['files']
|
||||
names = {f['path'] for f in files}
|
||||
if len(names) != len(files):
|
||||
raise Refusal('duplicate_expected_path')
|
||||
for f in files:
|
||||
p = owner / f['path']
|
||||
if p.is_symlink() or not p.is_file() or sha(p.read_bytes()) != f['candidate_sha256']:
|
||||
raise Refusal('reviewed_source_drift')
|
||||
# Every unrelated worktree/index entry is retained. No stash/reset/clean.
|
||||
return names, preserved_state(owner, names)
|
||||
|
||||
def commit_source(owner, expected, scanner, execute=False, push=False):
|
||||
names, before = validate(owner, expected)
|
||||
result = {'schema': 'receiver-source-publication.v1', 'scope': 'source_only',
|
||||
'deployed': False,
|
||||
'base_revision': expected['base_revision'], 'files': sorted(names),
|
||||
'preserved_before': before, 'status': 'validated_not_executed'}
|
||||
if not execute:
|
||||
return result
|
||||
# Stage exact reviewed paths first so the normal scanner inspects them.
|
||||
# A refusal retains reviewed source/staging for inspection; unrelated state stays intact.
|
||||
git(owner, 'add', '--', *sorted(names))
|
||||
scan = subprocess.run(scanner, cwd=owner, capture_output=True, timeout=150)
|
||||
result['scanner_exit'] = scan.returncode
|
||||
if scan.returncode:
|
||||
raise Refusal('normal_source_scanner_refused')
|
||||
# --only preserves unrelated staged content while committing exact paths.
|
||||
git(owner, 'commit', '--only', '-m',
|
||||
'docs(workplan): close verified historical receiving', '--', *sorted(names))
|
||||
commit = git(owner, 'rev-parse', 'HEAD').stdout.decode().strip()
|
||||
changed = set(git(owner, 'diff-tree', '--no-commit-id', '--name-only', '-r', commit).stdout.decode().splitlines())
|
||||
if changed != names:
|
||||
raise Refusal('committed_path_set_mismatch_stop')
|
||||
if preserved_state(owner, names) != before:
|
||||
raise Refusal('unrelated_state_changed_stop')
|
||||
result.update(status='committed_not_pushed', source_revision=commit,
|
||||
preserved_after=before)
|
||||
if push:
|
||||
# Explicit ref and bounded push. A nonzero or timeout never triggers retry.
|
||||
try:
|
||||
p = git(owner, 'push', 'origin', 'HEAD:refs/heads/main', check=False)
|
||||
result['push_exit'] = p.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
result.update(status='push_timeout_remote_unverified')
|
||||
return result
|
||||
remote = git(owner, 'ls-remote', '--exit-code', 'origin', 'refs/heads/main', check=False)
|
||||
actual = remote.stdout.split()[0].decode() if remote.returncode == 0 and remote.stdout else None
|
||||
result['observed_remote_revision'] = actual
|
||||
result['status'] = 'source_published_verified' if actual == commit else 'push_remote_unverified'
|
||||
return result
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--execute-source-commit', action='store_true')
|
||||
ap.add_argument('--push-source', action='store_true')
|
||||
args = ap.parse_args()
|
||||
if args.push_source:
|
||||
ap.error('direct push disabled; use normal repo-local make sync after exact source commit')
|
||||
publication_manifest = BASE / 'publication-manifest.json'
|
||||
if not publication_manifest.is_file():
|
||||
print(json.dumps({'status':'refused','reason':'reviewed_managed_UUID_T09_completion_manifest_not_ready','scope':'source_only'}))
|
||||
return 1
|
||||
expected = json.loads(publication_manifest.read_text())
|
||||
if expected.get('publication_stage') != 'root_reviewed_UUIDs_and_T09_completion':
|
||||
raise Refusal('publication_review_overlay_missing')
|
||||
if len(expected['files']) != 10:
|
||||
raise Refusal('expected_exact_ten_paths')
|
||||
try:
|
||||
result = commit_source(OWNER, expected, SCANNER, args.execute_source_commit, args.push_source)
|
||||
except (Refusal, subprocess.TimeoutExpired) as exc:
|
||||
result = {'schema': 'receiver-source-publication.v1', 'status': 'refused_or_unverified',
|
||||
'reason': str(exc) if isinstance(exc, Refusal) else 'bounded_command_timeout',
|
||||
'deployed': False}
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result['status'] in {'validated_not_executed','committed_not_pushed','source_published_verified'} else 1
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
from pathlib import Path
|
||||
import hashlib,json,importlib.util
|
||||
p=Path(__file__).resolve().parent;owner=Path('/home/worsch/state-hub')
|
||||
spec=importlib.util.spec_from_file_location('pub',p/'publish_owner_source.py');pub=importlib.util.module_from_spec(spec);spec.loader.exec_module(pub)
|
||||
d=json.loads((p/'manifest.json').read_text());e=d['files'][0];assert len(d['files'])==1
|
||||
assert pub.git(owner,'rev-parse','HEAD').stdout.decode().strip()==d['base_revision']
|
||||
assert hashlib.sha256((p/'state-hub-workplan.patch').read_bytes()).hexdigest()=='cb29c018ba5ebe189e3bf40b3c4889484ed622f6f538e5671b7f80ee200c3e08'
|
||||
f=owner/e['path'];s=p/'state-hub'/e['path'];assert not f.is_symlink() and not s.is_symlink()
|
||||
assert hashlib.sha256(f.read_bytes()).hexdigest()==e['base_sha256']
|
||||
assert hashlib.sha256(s.read_bytes()).hexdigest()==e['candidate_sha256']
|
||||
assert not pub.git(owner,'diff','--cached','--',e['path']).stdout
|
||||
before=pub.preserved_state(owner,{e['path']});f.write_bytes(s.read_bytes());assert pub.preserved_state(owner,{e['path']})==before
|
||||
result=pub.commit_source(owner,d,pub.SCANNER,execute=True,push=False)
|
||||
assert pub.preserved_state(owner,{e['path']})==before
|
||||
(p/'source-commit-result.json').write_text(json.dumps(result,indent=2)+'\n')
|
||||
print(json.dumps({k:v for k,v in result.items() if k not in {'preserved_before','preserved_after'}}))
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
from pathlib import Path
|
||||
import subprocess,json
|
||||
p=Path(__file__).resolve().parent
|
||||
r=subprocess.run(['make','sync','SYNC_PATH=.','SYNC_PUSH=1','STATE_HUB_API_BASE=http://127.0.0.1:8000'],cwd='/home/worsch/state-hub',capture_output=True,text=True,timeout=180)
|
||||
try:d=json.loads(r.stdout[r.stdout.index('{'):])
|
||||
except Exception:d={'status':'unverified','reason':'normal_sync_receipt_unparseable','exit':r.returncode}
|
||||
(p/'normal-sync-result.json').write_text(json.dumps(d,indent=2)+'\n')
|
||||
receipt=d.get('receipt',{})
|
||||
print(json.dumps({'exit':r.returncode,'status':d.get('status'),'commit':d.get('commit'),'instance_role':receipt.get('instance_role'),'instance_label':receipt.get('instance_label'),'expected_commit':receipt.get('expected_commit'),'derived_commit':receipt.get('derived_commit'),'identifier_assignments':d.get('identifiers',{}).get('assignments')}))
|
||||
raise SystemExit(r.returncode)
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"schema": "state95.finished-owner-readback.v1",
|
||||
"status": "applied",
|
||||
"source_revision": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a",
|
||||
"remote_revision": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a",
|
||||
"instance_role": "primary",
|
||||
"instance_label": "railiance01",
|
||||
"expected_commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a",
|
||||
"derived_commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a",
|
||||
"parent_uuid": "79579572-8120-5d3b-aca5-dd4aff60d058",
|
||||
"parent_status": "finished",
|
||||
"tasks": [
|
||||
{
|
||||
"uuid": "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7",
|
||||
"record_id": "STATE-WP-0095-T01",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
},
|
||||
{
|
||||
"uuid": "ebb24194-e4cc-5130-aaa6-663f2ea2c822",
|
||||
"record_id": "STATE-WP-0095-T02",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
},
|
||||
{
|
||||
"uuid": "e8566341-cf6e-5b9c-ad80-8564d14cd52a",
|
||||
"record_id": "STATE-WP-0095-T03",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
},
|
||||
{
|
||||
"uuid": "3bbb311f-194e-5862-8aed-358a580f22b4",
|
||||
"record_id": "STATE-WP-0095-T04",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
},
|
||||
{
|
||||
"uuid": "339b9a37-181c-5599-9a89-0393e199bd4b",
|
||||
"record_id": "STATE-WP-0095-T05",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
},
|
||||
{
|
||||
"uuid": "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4",
|
||||
"record_id": "STATE-WP-0095-T06",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
},
|
||||
{
|
||||
"uuid": "cfd1ea99-e75b-542e-a434-acefb62f3c7d",
|
||||
"record_id": "STATE-WP-0095-T07",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
},
|
||||
{
|
||||
"uuid": "2dd9b04f-2250-5a8a-b5bc-c8a009872858",
|
||||
"record_id": "STATE-WP-0095-T08",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
},
|
||||
{
|
||||
"uuid": "8be8f13b-6352-5295-9755-de6fbfb13593",
|
||||
"record_id": "STATE-WP-0095-T09",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
},
|
||||
{
|
||||
"uuid": "88585c69-d9b9-5e1f-a256-6416ff71e824",
|
||||
"record_id": "STATE-WP-0095-T10",
|
||||
"status": "done",
|
||||
"needs_human": false
|
||||
}
|
||||
],
|
||||
"whole_unrelated_index_dirty_preserved": true,
|
||||
"new_UUIDs": false,
|
||||
"runtime_mutated_by_closure": false,
|
||||
"declared_runtime_source": "c103cbf3c8bed5dba87a03e320595ddded9301a3",
|
||||
"original_three_parent_failure": "retained_approved_historical_exception_no_guessed_restoration_no_backfill",
|
||||
"residual_audit": "No own unfinished receiving/registrar-return work. Existing separate security-target/native/TLS/recovery/MFA work records remain outside this closure."
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
--- a/workplans/STATE-WP-0095-historical-source-receiving.md
|
||||
+++ b/workplans/STATE-WP-0095-historical-source-receiving.md
|
||||
@@ -4,7 +4,7 @@
|
||||
title: "Preserve exact closed historical source bindings in Forge receiving"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
-status: active
|
||||
+status: finished
|
||||
owner: codex
|
||||
created: "2026-10-04"
|
||||
updated: "2026-10-17"
|
||||
@@ -235,7 +235,7 @@
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T07
|
||||
-status: wait
|
||||
+status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10]
|
||||
state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d"
|
||||
@@ -420,3 +420,26 @@
|
||||
security-target gate or broader autonomy is established by this infrastructure
|
||||
return. Source appVersion and both maintained image pins now declare this same
|
||||
verified image; the approved frozen rollout archive remains unchanged.
|
||||
+
|
||||
+
|
||||
+T07 final owner return and STATE95 closure, 2026-10-17: Repo Manager published
|
||||
+RMGR-WP-0019 finished with T06 done at source/remote
|
||||
+3bcb1e1cf3e58b263f690534f7f28024d75cdbf6. Its normal authoritative projection
|
||||
+is applied on primary/railiance01 at that exact expected/derived commit; all
|
||||
+six source task UUIDs/statuses and finished parent were independently read back.
|
||||
+This completes the remaining registrar-return dependency after T10's verified
|
||||
+revision-2 delivery, eight rollback-only native cases and actual corrected
|
||||
+ordinary Cluster pipeline preserved all 18 CURRENT full-row hashes, the 15
|
||||
+original task rows and three archived source byte digests.
|
||||
+
|
||||
+The immutable runtime return remains
|
||||
+sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a.
|
||||
+The founder-approved original three-parent mismatch remains a retained
|
||||
+historical exception; no unavailable original preimages were reconstructed,
|
||||
+no failed trial was relabeled, and no original hash restoration is claimed.
|
||||
+All ten STATE95 tasks are now done. The residual audit found no unfinished
|
||||
+work inside this receiving/registrar-return scope; separate security-target,
|
||||
+TLS/recovery/MFA and owner-native admission work remains with its existing
|
||||
+live records and is not accepted by this infrastructure workplan closure.
|
||||
+The runtime remains the verified c103cbf/4cc4ce17 artifact; this later source-only
|
||||
+workplan commit changes neither its source pin nor the frozen rollout archive.
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
---
|
||||
id: STATE-WP-0095
|
||||
type: workplan
|
||||
title: "Preserve exact closed historical source bindings in Forge receiving"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: finished
|
||||
owner: codex
|
||||
created: "2026-10-04"
|
||||
updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058"
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Normal Forge receiving must preserve the seven bound Activity legacy source
|
||||
views and the Custodian retired ADHOC source without new identities, source
|
||||
rewrites, task movement, deletion, relabeling or unretirement. This implements
|
||||
receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns
|
||||
cross-owner coordination; deployment and replay remain explicit owner returns.
|
||||
|
||||
## Verify source bindings and shared views
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7"
|
||||
```
|
||||
|
||||
Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound
|
||||
closed tasks. The archived custodian-WP-0003 parent and three split_from views
|
||||
share one existing parent UUID; every repeated task binding/content agrees.
|
||||
Custodian ADHOC-2026-08-25-retired retains parent
|
||||
1c53d3db-6633-5e2d-987f-10706d73be9e and task
|
||||
d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized.
|
||||
|
||||
## Implement preservation receiving and global collision refusal
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822"
|
||||
```
|
||||
|
||||
Prepared exact source digest manifest, existing-parent/task verification,
|
||||
immutable receiving and UUID-based historical diff. Ordinary canonical sources
|
||||
continue deriving their identities. Missing/changed/symlinked source, absent
|
||||
bound rows or nonterminal hub histories refuse. Check task UUID holders globally,
|
||||
including retired and acknowledged-stale parent workplans, before INSERT.
|
||||
|
||||
Offline preview tests and snapshot proof are evidence of source behavior only;
|
||||
finish after applying reviewed source and its focused tests to the owning repo.
|
||||
|
||||
## Deliver receiving through normal deployment and verify owner return
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T03
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T02]
|
||||
state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a"
|
||||
decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca"
|
||||
```
|
||||
|
||||
No direct live code patch. This task is a deployment return, not policy authority
|
||||
or permission to resolve identity decisions. Exact receiving fixture acceptance
|
||||
and global collision rejection must hold in the delivered version.
|
||||
|
||||
## Verify normal historical replay preserves existing identities
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T04
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03]
|
||||
state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4"
|
||||
```
|
||||
|
||||
Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired
|
||||
parent/task unchanged; no historical row creation, movement, deletion,
|
||||
relabeling or unretirement. Unrelated stale projections remain separate decisions.
|
||||
|
||||
2026-10-05: Source exact-set retirement support now exists. Repository projection
|
||||
reconcile accepts `expected_retirement_uuids`; that set must equal the stale
|
||||
projections, and any task or dependency edge on a stale row refuses the call.
|
||||
Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted
|
||||
review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three
|
||||
empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`.
|
||||
That acceptance is not a live retirement and does not resolve
|
||||
`91b7c633-a66f-5d43-9f4b-6565aefb9356`.
|
||||
|
||||
## Offline evidence, 2026-10-04
|
||||
|
||||
Prepared receiver suite: Forge projection regression plus historical preservation,
|
||||
missing/changed source, terminality, immutable UUID diff, and global holder query.
|
||||
Real-source offline snapshot proof preserves four Activity parents/64 tasks and
|
||||
one Custodian parent/task without mutations; no API writes or deployment occurred.
|
||||
Exact original bindings are maintained in api/services/historical_projection.py.
|
||||
|
||||
2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner
|
||||
cases passed; the independent offline current-source/current-hub-snapshot proof
|
||||
retained five existing parents and 65 task UUIDs, no mutations, clean historical
|
||||
diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and
|
||||
normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json.
|
||||
|
||||
|
||||
## Verified receiving rollout and remaining replay gate, 2026-10-04
|
||||
|
||||
T03 is done on actual founder approval of proposal
|
||||
STATE-WP-0095-T03-historical-receiving revision 1, SHA256
|
||||
`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`,
|
||||
approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`.
|
||||
The normal owner Helm path delivered `main-5dd3df9`, commit
|
||||
`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready,
|
||||
primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source
|
||||
hashes match. Rollback remains revision 71; no receipt-table downgrade is needed.
|
||||
Actual historical preservation and global task-holder refusal were verified in
|
||||
the delivered version. Durable receipt:
|
||||
[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json).
|
||||
|
||||
Custodian immutable replay applied while preserving the retired historical
|
||||
parent/task UUIDs. Activity immutable replay retained all four historical parent
|
||||
and 64 task UUIDs and historical row contents, but refused transactionally on
|
||||
three unrelated stale projections: custodian-wp-0003 (archived),
|
||||
custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement
|
||||
was issued and that refusal is not represented as normal replay success.
|
||||
|
||||
Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own
|
||||
normal sync succeeded; registrar admission does not authorize retiring Activity
|
||||
projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting
|
||||
Activity Core's precise reviewed disposition of those three stale rows. No
|
||||
rename, identity migration, new paid native trial or further live action is
|
||||
included in this source status update.
|
||||
|
||||
## Activity Core return, 2026-10-06
|
||||
|
||||
Activity Core returned the exact disposition (message
|
||||
`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit
|
||||
`941fac0`). `expected_retirement_uuids`:
|
||||
`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003),
|
||||
`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a),
|
||||
`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero
|
||||
tasks and zero dependency edges. None is preserved parent
|
||||
`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the
|
||||
set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired.
|
||||
T04 now waits on decision `91b7c633` and a reviewed live rollout. After that,
|
||||
Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03.
|
||||
|
||||
## Retirement rollout and T04 verification, 2026-10-06
|
||||
|
||||
The founder resolved decision `91b7c633` (retire exactly the three projections)
|
||||
and approved the rollout. The founder ran the normal Helm upgrade to
|
||||
`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP
|
||||
are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is
|
||||
unchanged; no migrations. The activity-core reconcile at commit
|
||||
`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set
|
||||
to the three UUIDs applied: 0 created, 3 retired (tombstoned with an
|
||||
`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks
|
||||
cancelled. All 7 bound Activity source files still match their digests. The four
|
||||
historical parents and 64 task UUIDs are present under their bound parents with
|
||||
unchanged fields. The Custodian retired parent and task are unchanged. Activity
|
||||
had 54 workplans and 288 tasks before and after. Evidence:
|
||||
[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json).
|
||||
Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and
|
||||
ACTIVITY-WP-0045-T02 remain with their owners.
|
||||
|
||||
|
||||
## Cluster exact historical receiving successor, 2026-10-17
|
||||
|
||||
This scoped extension retains the completed Activity/Custodian outcomes above.
|
||||
Three frozen cluster archives contain three existing parent UUIDs and 15 existing
|
||||
closed task UUIDs. Extend the existing exact historical receiving path before
|
||||
registrar admission; never derive replacement identities or modify those
|
||||
sources. Cross-owner observation attaches to existing SHR-WP-0001, with
|
||||
RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly.
|
||||
The local derived case is waiting; no live worker injection is authorized.
|
||||
|
||||
## Prepare exact cluster source preservation and refusal cases
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b"
|
||||
```
|
||||
|
||||
Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager.
|
||||
Candidate receiving tests pass 26: existing rows remain entirely unchanged;
|
||||
missing/foreign/nonterminal rows and unknown, changed, missing or symlinked
|
||||
source refuse. Read-only public metadata confirms all 18 existing rows have the
|
||||
expected ownership and terminal status. These are source and metadata checks,
|
||||
not delivered receiving or a committed native trial.
|
||||
|
||||
## Deliver reviewed cluster receiving successor before registrar admission
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T06
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T05]
|
||||
needs_human: false
|
||||
intervention_note: "Exact revision 4 approved and verified; no pending rollout intervention."
|
||||
state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4"
|
||||
```
|
||||
|
||||
Completed 2026-10-17 on the founder's approval of exact revision 4 of stable
|
||||
proposal STATE-WP-0095-T06-cluster-frozen-receiving-r1 (decision
|
||||
e3798419-8aa9-5425-8c08-20df6bd82a19; approved digest
|
||||
sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32).
|
||||
The ordinary owner Helm path delivered revision 76, runtime source 51bc70d and
|
||||
exact image manifest sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015.
|
||||
API/MCP Ready and primary/railiance01 health report expected schema
|
||||
c4d5e6f7a8b9; delivered historical module sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2 matches.
|
||||
All three parent and 15 task full DB row hashes/columns/UUIDs remained unchanged.
|
||||
Five actual native probes passed: preserve, actual foreign-parent holder move,
|
||||
nonterminal task, nonterminal parent and missing task. Every probe rolled back;
|
||||
a fresh transaction confirmed complete row restoration. The foreign-holder
|
||||
case precisely verified historical binding refusal, not the separate global
|
||||
UUID-holder creation branch. No committed probe writes were made.
|
||||
|
||||
Evidence: [verified receiving rollout](../docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json).
|
||||
This actual receiving return satisfies the prerequisite of RMGR-WP-0019-T05.
|
||||
Normal cluster publication and its immutable-source/row return remain T07;
|
||||
source readiness and this deployment do not count that later acceptance twice.
|
||||
The additive inbox migration is retained; Helm rollback does not downgrade
|
||||
schema or stop an active migration Job. Original failures and exact-UID recovery
|
||||
limits remain in the referenced approved packet. No TLS recovery/native private
|
||||
admission or new autonomy/cost grant is inferred.
|
||||
|
||||
## Verify ordinary cluster receiving and immutable historical rows
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T07
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10]
|
||||
state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d"
|
||||
```
|
||||
|
||||
Capture normal cluster owner publication/receiving receipts and compare entire
|
||||
existing row snapshots and exact source bytes. No creation, movement, relabel,
|
||||
unretirement or retirement is authorized for these histories. A rollback first
|
||||
fences cluster sync and restores registrar refusal before removing receiving
|
||||
support; it never rewrites archived IDs or UUIDs. This work does not establish
|
||||
original TLS recovery or owner-native admission.
|
||||
|
||||
## Fix policy-compatible Secret-template server dry-run preparation
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T08
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858"
|
||||
```
|
||||
|
||||
Prepared the narrow Makefile successor after the existing readiness command
|
||||
refused its first Secret-template phase. Ordinary client-side apply adds the
|
||||
last-applied annotation, correctly denied by the existing Platform
|
||||
reject-secret-last-applied policy. Only the two Secret-template server
|
||||
dry-run requests now use server-side apply with dry-run; no force-conflicts,
|
||||
Secret value change, policy relaxation or actual Secret write is introduced.
|
||||
Command-fixture tests verify both requests and preserve other dry-run phases.
|
||||
Evidence and original refusal remain in the Custodian cluster historical
|
||||
publication packet, revisions/r2 and r3-readiness. This is completed source
|
||||
preparation; T06 separately records the verified actual rollout. The runtime image
|
||||
continues to be main-51bc70d; Makefile/workplan changes do not create a new
|
||||
application image or native acceptance.
|
||||
|
||||
|
||||
T08 source repair refinement, 2026-10-17: the required normal Make render
|
||||
correctly refused a pre-existing generated chart classification copy at v1.0
|
||||
after authoritative canon reached v1.1. Regenerated only through the existing
|
||||
scripts/sync_classification_allowed.py, preserving canon and strict check.
|
||||
The exact source diff is version 1.0→1.1 plus publication/history/participation.
|
||||
Normal Make rendering and the focused classification check passed in an
|
||||
isolated owner-layout copy. The original refusal is retained in the Custodian
|
||||
owner-followthrough-r1 validation receipt. This is source repair only: no live
|
||||
ConfigMap apply, new runtime image, policy relaxation or T07 completion.
|
||||
|
||||
|
||||
T07 actual ordinary-publication return, 2026-10-17: cluster owner deep consistency
|
||||
and normal fast projection completed, exact archived source bytes and all 15
|
||||
historical task full-row hashes remained unchanged. The later full-row check
|
||||
FAILED for exactly the three parent rows. The earlier approved T06 rollout had
|
||||
preserved all 18 rows at its verification stage; that success is not the later
|
||||
whole-pipeline acceptance. The original snapshots retain hashes only, so exact
|
||||
changed columns and original field preimages are not established. No guessed
|
||||
DB repair or claim that all 18 original row hashes are restored is authorized.
|
||||
T07 and RMGR-WP-0019-T06 remain waiting pending T09/T10 and a reviewed return.
|
||||
|
||||
## Guard immutable historical index bindings in CLI and receiving API
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T09
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T06]
|
||||
state_hub_task_id: "8be8f13b-6352-5295-9755-de6fbfb13593"
|
||||
```
|
||||
|
||||
Prepare and publish the narrowly scoped ordinary-binding repair for the three
|
||||
existing task-bound Cluster registry entries. The CLI verifies exact frozen
|
||||
source metadata/bytes and omits those bindings, including on old receivers.
|
||||
The API independently validates the complete batch before any mutation, checks
|
||||
actual repo ownership and the existing terminal parent/task closure, and
|
||||
preserves valid frozen rows without field or timestamp assignments. Invalid
|
||||
metadata, missing/foreign/nonterminal rows refuse the batch. Unknown ordinary
|
||||
bindings retain existing behavior; historical IDs, UUIDs and source bytes stay
|
||||
unchanged. Isolated regression tests and normal owner rendering are required.
|
||||
Reviewed source preparation and canonical regression proof completed on
|
||||
2026-10-17: 102 isolated tests pass and normal Make render/classification
|
||||
check passes (eight objects, three unchanged delivered runtime images).
|
||||
This completion record accompanies the same source publication/projection;
|
||||
source preparation does not establish delivered runtime or T07 acceptance.
|
||||
Central check/fix engines also refuse the complete repair batch if any fixable
|
||||
context touches an exact registered parent/task UUID or frozen source path.
|
||||
This runs before index bindings and before reconcile/push/individual fixes;
|
||||
ordinary earlier fixes cannot partially execute before a later frozen refusal.
|
||||
|
||||
## Deliver and verify the ordinary-binding guard successor
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T10
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T09]
|
||||
needs_human: false
|
||||
intervention_note: "Exact revision 2 approved and verified; no pending rollout intervention."
|
||||
state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824"
|
||||
```
|
||||
|
||||
After source publication, resolve normal CI status and immutable image identity,
|
||||
verify delivered guard source closure, prepare exact chart/reuse-values dry-run,
|
||||
full closure/headroom/schema baseline and conditional recovery. Retain additive
|
||||
migration and active-Job cancellation limitations; no Helm schema downgrade or
|
||||
bounded-outage claim. Obtain scoped approval before live rollout. Then verify
|
||||
new full-row baseline preservation and actual rollback-only refusal probes,
|
||||
and only then return to T07 ordinary-pipeline acceptance. This prevents future
|
||||
binding mutation; it does not reconstruct or restore original parent hashes.
|
||||
No production changes, new autonomy or credential authority are granted here.
|
||||
|
||||
|
||||
T09 source repair refinement, 2026-10-17: the attempted current-client regression
|
||||
actually loaded an older packaged consistency checker from the owner virtualenv,
|
||||
not the reviewed canonical source. That trial again failed parent-row hash
|
||||
preservation; it is retained separately and does not count as R5 client proof.
|
||||
Normal C-23 also legitimately normalized WP18 ready to active and created a
|
||||
source commit, but the generic writeback commit included an unrelated staged
|
||||
brief. A narrow successor scopes git add and git commit --only to the actual
|
||||
writeback file, preserving unrelated index/worktree state. Real disposable Git
|
||||
regression tests cover pre-existing staged changes, hooks, no-op and path names.
|
||||
Verify the actually loaded CLI checker hash after ordinary owner package refresh
|
||||
before any retry; capture legitimate source HEAD transitions rather than assume
|
||||
a static revision. T07 and T10 remain wait. No original parent preimage recovery,
|
||||
new runtime delivery or broader authority follows from this source repair.
|
||||
|
||||
|
||||
T07 current-baseline client return, 2026-10-17: after the owner refreshed the
|
||||
installed package and verified the actually loaded checker matched the reviewed
|
||||
R6 source, ordinary Cluster deep consistency and fast projection passed.
|
||||
Bindings synced 20/20, omitting the three frozen histories; all three parent
|
||||
and 15 task CURRENT full-row hashes remained unchanged across
|
||||
before-verified-client.json / after-verified-client.json. Unrelated staged brief
|
||||
state was preserved. This proves the corrected installed client stage against
|
||||
its fresh current baseline; earlier parent-hash failures remain retained
|
||||
historical exceptions, not restored preimages or backfilled successes.
|
||||
|
||||
T10 remains wait/needs_human for exact proposal
|
||||
STATE-WP-0095-T10-frozen-bindings-guard-r1, now revision 2, metadata
|
||||
sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e,
|
||||
pending decision df703266-6719-4c04-ae1b-ba9afd3bca1c. The inspected candidate
|
||||
image is main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1,
|
||||
from runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3. Image-only
|
||||
server dry-run passed eight identities with only three image-field changes.
|
||||
The exact proposal and recovery limits are retained in the Custodian dated
|
||||
2026-10-17 guard-successor-review-r2 packet. No new API runtime is approved or
|
||||
delivered by this source-only note; its later workplan commit does not change
|
||||
the image's source pin. T09 stays done, T07/T10 stay wait pending reviewed API
|
||||
delivery and explicit successor/current-baseline acceptance disposition.
|
||||
|
||||
|
||||
T10 proposal revision history: revision 1 metadata
|
||||
sha256:81d365926fc585a186955d8c98ee83501fd3cec12135b2256d71b46a75097932
|
||||
and its original helper remain immutable local evidence, unapproved and
|
||||
unexecuted. Revision 2 of the same stable proposal changes only the native
|
||||
probe's EXPECTED hash metadata into module/sha256 records for normal publication
|
||||
scanning; probe behavior, exact image, recovery and acceptance scope remain
|
||||
unchanged. The same decision df703266-6719-4c04-ae1b-ba9afd3bca1c remains open
|
||||
and pending. This source-only amendment grants no runtime authority or new
|
||||
image/code change; T07/T10 remain wait and T09 remains done.
|
||||
|
||||
|
||||
T10 verified receiving return, 2026-10-17: the founder approved and the owner
|
||||
executed exact revision 2 of STATE-WP-0095-T10-frozen-bindings-guard-r1,
|
||||
sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e,
|
||||
under decision df703266-6719-4c04-ae1b-ba9afd3bca1c. Helm revision 77 delivered
|
||||
runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3 and image
|
||||
main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1.
|
||||
API/MCP are Ready on expected schema c4d5e6f7a8b9. Eight actual native cases
|
||||
passed with zero probe commits. All three parent and 15 task CURRENT full-row
|
||||
hashes remained unchanged after delivery/probes and the actual normal Cluster
|
||||
pipeline (20 ordinary bindings, three frozen bindings omitted by the verified
|
||||
loaded checker). All three archived sources remained byte-identical. Exact
|
||||
owned Job/pod success was observed, then their absence and removal of owned
|
||||
chart staging were verified; no recovery was needed.
|
||||
|
||||
The verified return is the Custodian dated 2026-10-17
|
||||
guard-successor-execution-r2/verified-receiving-return.json,
|
||||
sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a.
|
||||
The original three-parent hash failures remain the founder-accepted historical
|
||||
exception: unavailable original preimages were not guessed/restored, and failed
|
||||
outcomes were not backfilled. The 15 original task rows and archived bytes stay
|
||||
preserved. Earlier pending review notes are retained as proposal history; T10
|
||||
is now done and needs_human false. T07 and this parent remain open until the
|
||||
actual RMGR-WP-0019-T06 owner closure is published and returned. No native
|
||||
security-target gate or broader autonomy is established by this infrastructure
|
||||
return. Source appVersion and both maintained image pins now declare this same
|
||||
verified image; the approved frozen rollout archive remains unchanged.
|
||||
|
||||
|
||||
T07 final owner return and STATE95 closure, 2026-10-17: Repo Manager published
|
||||
RMGR-WP-0019 finished with T06 done at source/remote
|
||||
3bcb1e1cf3e58b263f690534f7f28024d75cdbf6. Its normal authoritative projection
|
||||
is applied on primary/railiance01 at that exact expected/derived commit; all
|
||||
six source task UUIDs/statuses and finished parent were independently read back.
|
||||
This completes the remaining registrar-return dependency after T10's verified
|
||||
revision-2 delivery, eight rollback-only native cases and actual corrected
|
||||
ordinary Cluster pipeline preserved all 18 CURRENT full-row hashes, the 15
|
||||
original task rows and three archived source byte digests.
|
||||
|
||||
The immutable runtime return remains
|
||||
sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a.
|
||||
The founder-approved original three-parent mismatch remains a retained
|
||||
historical exception; no unavailable original preimages were reconstructed,
|
||||
no failed trial was relabeled, and no original hash restoration is claimed.
|
||||
All ten STATE95 tasks are now done. The residual audit found no unfinished
|
||||
work inside this receiving/registrar-return scope; separate security-target,
|
||||
TLS/recovery/MFA and owner-native admission work remains with its existing
|
||||
live records and is not accepted by this infrastructure workplan closure.
|
||||
The runtime remains the verified c103cbf/4cc4ce17 artifact; this later source-only
|
||||
workplan commit changes neither its source pin nor the frozen rollout archive.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"schema": "state95.registrar-closure-return.v1",
|
||||
"registrar_source": "3bcb1e1cf3e58b263f690534f7f28024d75cdbf6",
|
||||
"registrar_remote": "3bcb1e1cf3e58b263f690534f7f28024d75cdbf6",
|
||||
"parent_status": "finished",
|
||||
"tasks": [
|
||||
{
|
||||
"record_id": "RMGR-WP-0019-T01",
|
||||
"uuid": "d78a622e-39f9-5923-b4e1-f70fdc17fb42",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "RMGR-WP-0019-T02",
|
||||
"uuid": "95240c81-1805-5365-9b35-d238621a16bf",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "RMGR-WP-0019-T03",
|
||||
"uuid": "682eea14-a4a8-5f33-9a9f-8f2f1ac8288d",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "RMGR-WP-0019-T04",
|
||||
"uuid": "1ccb06c6-ad30-5847-8704-e5628ba81f35",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "RMGR-WP-0019-T05",
|
||||
"uuid": "36f47e21-42d0-5aef-b065-4a5dd3af0726",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"record_id": "RMGR-WP-0019-T06",
|
||||
"uuid": "4d1675b6-f49f-5b01-8d76-bbf2030d1b34",
|
||||
"status": "done"
|
||||
}
|
||||
],
|
||||
"projection_status": "applied",
|
||||
"instance_role": "primary",
|
||||
"instance_label": "railiance01",
|
||||
"expected_commit": "3bcb1e1cf3e58b263f690534f7f28024d75cdbf6",
|
||||
"derived_commit": "3bcb1e1cf3e58b263f690534f7f28024d75cdbf6",
|
||||
"verified_runtime_receipt_sha256": "sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a",
|
||||
"residual_audit": "STATE95 all other9tasks done; receiving/ordinarypipeline/registrar-return scope complete. Earlier3parent failure is approved retained exception, not unresolved repair. Security target/TLS/recovery/MFA/native admission remain existing separate live driver records outside STATE95; no new residual invented."
|
||||
}
|
||||
|
|
@ -0,0 +1,972 @@
|
|||
--- a/src/repo_manager/historical_sources.py
|
||||
+++ b/src/repo_manager/historical_sources.py
|
||||
@@ -70,6 +70,111 @@
|
||||
"record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED",
|
||||
"uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e",
|
||||
"parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED"
|
||||
+ },
|
||||
+ {
|
||||
+ "repo": "railiance-cluster",
|
||||
+ "path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md",
|
||||
+ "sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9",
|
||||
+ "record_id": "RAIL-BS-WP-0002",
|
||||
+ "uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d",
|
||||
+ "parent_record_id": "RAIL-BS-WP-0002",
|
||||
+ "tasks": [
|
||||
+ {
|
||||
+ "label": "T01",
|
||||
+ "uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T02",
|
||||
+ "uuid": "e8510646-46ed-4697-a345-f3d3009eea78",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T03",
|
||||
+ "uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T04",
|
||||
+ "uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T05",
|
||||
+ "uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f",
|
||||
+ "status": "done"
|
||||
+ }
|
||||
+ ]
|
||||
+ },
|
||||
+ {
|
||||
+ "repo": "railiance-cluster",
|
||||
+ "path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md",
|
||||
+ "sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a",
|
||||
+ "record_id": "RAIL-BS-WP-0003",
|
||||
+ "uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b",
|
||||
+ "parent_record_id": "RAIL-BS-WP-0003",
|
||||
+ "tasks": [
|
||||
+ {
|
||||
+ "label": "T01",
|
||||
+ "uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T02",
|
||||
+ "uuid": "ab166073-30a7-4702-a037-4091e8706e20",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T03",
|
||||
+ "uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T04",
|
||||
+ "uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d",
|
||||
+ "status": "done"
|
||||
+ }
|
||||
+ ]
|
||||
+ },
|
||||
+ {
|
||||
+ "repo": "railiance-cluster",
|
||||
+ "path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md",
|
||||
+ "sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3",
|
||||
+ "record_id": "RAIL-BS-WP-0004",
|
||||
+ "uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625",
|
||||
+ "parent_record_id": "RAIL-BS-WP-0004",
|
||||
+ "tasks": [
|
||||
+ {
|
||||
+ "label": "T01",
|
||||
+ "uuid": "4526a842-ea31-4874-9231-92ab556cfe7b",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T02",
|
||||
+ "uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T03",
|
||||
+ "uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T04",
|
||||
+ "uuid": "08233868-d522-4117-bc4e-6c0f52545665",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T05",
|
||||
+ "uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T06",
|
||||
+ "uuid": "f8e4a094-c367-40eb-b895-da17bc144b07",
|
||||
+ "status": "done"
|
||||
+ }
|
||||
+ ]
|
||||
}
|
||||
]''')
|
||||
|
||||
@@ -83,3 +188,24 @@
|
||||
raise ValueError("Historical source binding requires owner review: " + entry["path"])
|
||||
paths.add(entry["path"])
|
||||
return paths
|
||||
+
|
||||
+
|
||||
+def historical_source_occurrences(repo_root):
|
||||
+ """Exact frozen closed task occurrences; never a general local-ID alias."""
|
||||
+ from repo_manager.parse.workplan import parse_workplan_file
|
||||
+ from uuid import UUID
|
||||
+ paths=historical_source_paths(repo_root)
|
||||
+ occurrences=set(paths)
|
||||
+ for entry in BINDINGS:
|
||||
+ if entry['repo']!=repo_root.name or 'tasks' not in entry:continue
|
||||
+ parsed=parse_workplan_file(repo_root/entry['path'],repo_root=repo_root)
|
||||
+ actual=[{'label':task.id,'uuid':task.state_hub_task_id,'status':task.status} for task in parsed.tasks]
|
||||
+ if (parsed.id!=entry['record_id'] or parsed.state_hub_workstream_id!=entry['uuid']
|
||||
+ or parsed.status not in {'finished','archived','completed'} or actual!=entry['tasks']
|
||||
+ or any(task['status'] not in {'done','cancel'} for task in actual)):
|
||||
+ raise ValueError('Historical task binding requires owner review: '+entry['path'])
|
||||
+ UUID(entry['uuid'])
|
||||
+ for number,task in enumerate(actual,1):
|
||||
+ UUID(task['uuid'])
|
||||
+ occurrences.add(entry['path']+'#task-block-'+str(number))
|
||||
+ return occurrences
|
||||
--- a/src/repo_manager/identifiers.py
|
||||
+++ b/src/repo_manager/identifiers.py
|
||||
@@ -854,8 +854,9 @@
|
||||
# allocation candidates. Keep conformance reporting strict while allowing
|
||||
# ordinary registration to preserve those bindings. Local aliases still
|
||||
# require the explicit parent allowlist in the kind registry.
|
||||
- from repo_manager.historical_sources import historical_source_paths
|
||||
- historical_sources: set[str] = historical_source_paths(repo_root)
|
||||
+ from repo_manager.historical_sources import historical_source_occurrences
|
||||
+ frozen_sources = historical_source_occurrences(repo_root)
|
||||
+ historical_sources: set[str] = set(frozen_sources)
|
||||
for path in iter_workplan_files(repo_root):
|
||||
parsed = parse_workplan_file(path, repo_root=repo_root)
|
||||
if parsed.status not in {"finished", "archived", "completed"} or not parsed.id:
|
||||
@@ -889,11 +890,18 @@
|
||||
item for item in identity["invalid_identifiers"]
|
||||
if item["source"] in historical_sources
|
||||
]
|
||||
- if invalid or identity["identity_collisions"]:
|
||||
+ # Strict scans keep legacy nonconformance. Only collisions wholly contained
|
||||
+ # in exact hash-bound closed task occurrences can be preserved by receiving;
|
||||
+ # any unknown/live source or qualified/workplan collision remains fatal.
|
||||
+ historical_collisions=[item for item in identity['identity_collisions']
|
||||
+ if item['kind']=='task' and re.fullmatch(r'T[0-9]{2}',item['id'])
|
||||
+ and all(source in frozen_sources for source in item['sources'])]
|
||||
+ collisions=[item for item in identity['identity_collisions'] if item not in historical_collisions]
|
||||
+ if invalid or collisions:
|
||||
raise ValueError(
|
||||
"work-record identities are not safe to derive: "
|
||||
f"invalid={invalid!r}, "
|
||||
- f"collisions={identity['identity_collisions']!r}"
|
||||
+ f"collisions={collisions!r}"
|
||||
)
|
||||
|
||||
rendered: dict[Path, str] = {}
|
||||
@@ -966,6 +974,7 @@
|
||||
"namespace": namespace,
|
||||
"assignments": assignments,
|
||||
"historical_invalid_identifiers": historical,
|
||||
+ "historical_identity_collisions": historical_collisions,
|
||||
"files_changed": sorted(str(path.relative_to(repo_root)) for path in rendered),
|
||||
}
|
||||
|
||||
--- /dev/null
|
||||
+++ b/tests/test_cluster_frozen_history.py
|
||||
@@ -0,0 +1,52 @@
|
||||
+import shutil
|
||||
+from pathlib import Path
|
||||
+import pytest
|
||||
+from repo_manager.identifiers import ensure_missing_work_record_identifiers as ensure
|
||||
+from repo_manager.record_identity import scan_record_identities
|
||||
+
|
||||
+FIXTURE=Path(__file__).parent/'fixtures/railiance-cluster'
|
||||
+@pytest.fixture
|
||||
+def repo(tmp_path):
|
||||
+ root=tmp_path/'railiance-cluster';shutil.copytree(FIXTURE,root)
|
||||
+ live=root/'workplans/RCLUSTER-WP-0018.md';live.write_text('---\nid: RCLUSTER-WP-0018\nstatus: ready\n---\n\n```task\nid: RCLUSTER-WP-0018-T01\nstatus: todo\n```\n')
|
||||
+ return root,live
|
||||
+
|
||||
+def test_exact_three_histories_preserve_15_bindings_and_strict_findings(repo):
|
||||
+ root,live=repo;archive={p:p.read_bytes() for p in (root/'workplans/archived').glob('*.md')};before=scan_record_identities(root)
|
||||
+ assert len(before['invalid_identifiers'])==15 and len(before['identity_collisions'])==5
|
||||
+ preview=ensure(root,execute=False);assert len(preview['assignments'])==2
|
||||
+ assert preview['historical_invalid_identifiers']==before['invalid_identifiers']
|
||||
+ assert preview['historical_identity_collisions']==before['identity_collisions']
|
||||
+ result=ensure(root,execute=True);assert len(result['assignments'])==2
|
||||
+ assert all(p.read_bytes()==raw for p,raw in archive.items())
|
||||
+ assert not scan_record_identities(root)['ok']
|
||||
+ assert ensure(root,execute=False)['assignments']==[]
|
||||
+
|
||||
+@pytest.mark.parametrize('change',['bytes','missing','symlink','live','nonterminal','uuid'])
|
||||
+def test_unknown_history_change_refuses_before_any_allocation(repo,change):
|
||||
+ root,live=repo;p=next((root/'workplans/archived').glob('*.md'));before=live.read_bytes()
|
||||
+ raw=p.read_bytes()
|
||||
+ if change=='missing':p.unlink()
|
||||
+ elif change=='symlink':p.unlink();target=root/'saved';target.write_bytes(raw);p.symlink_to(target)
|
||||
+ elif change=='live':p.write_bytes(raw.replace(b'status: finished',b'status: active',1))
|
||||
+ elif change=='nonterminal':p.write_bytes(raw.replace(b'status: done',b'status: todo',1))
|
||||
+ elif change=='uuid':p.write_bytes(raw.replace(b'state_hub_task_id:',b'old_task_id:',1))
|
||||
+ else:p.write_bytes(raw+b'\nunreviewed change\n')
|
||||
+ with pytest.raises(ValueError):ensure(root)
|
||||
+ assert live.read_bytes()==before
|
||||
+
|
||||
+def test_any_unknown_or_live_source_in_collision_still_refuses(repo):
|
||||
+ root,live=repo;before=live.read_bytes();other=root/'workplans/unknown.md'
|
||||
+ other.write_text('---\nid: RCLUSTER-WP-0099\nstatus: active\n---\n\n```task\nid: T01\nstatus: todo\nstate_hub_task_id: 11111111-1111-4111-8111-111111111111\n```\n')
|
||||
+ with pytest.raises(ValueError,match='identities are not safe'):ensure(root)
|
||||
+ assert live.read_bytes()==before
|
||||
+
|
||||
+def test_other_repo_does_not_inherit_exact_frozen_exception(repo):
|
||||
+ root,live=repo;other=root.with_name('other');root.rename(other)
|
||||
+ with pytest.raises(ValueError,match='identities are not safe'):ensure(other)
|
||||
+
|
||||
+def test_qualified_collision_stays_global(repo):
|
||||
+ root,live=repo;before=live.read_bytes()
|
||||
+ (root/'workplans/other.md').write_text('---\nid: RCLUSTER-WP-0019\nstatus: active\n---\n\n```task\nid: RCLUSTER-WP-0018-T01\nstatus: done\nstate_hub_task_id: 11111111-1111-4111-8111-111111111111\n```\n')
|
||||
+ with pytest.raises(ValueError,match='collisions='):ensure(root)
|
||||
+ assert live.read_bytes()==before
|
||||
--- a/workplans/RMGR-WP-0019-historical-source-admission.md
|
||||
+++ b/workplans/RMGR-WP-0019-historical-source-admission.md
|
||||
@@ -4,10 +4,10 @@
|
||||
title: "Admit exact historical source views after receiving preservation support"
|
||||
domain: infotech
|
||||
repo: repo-manager
|
||||
-status: finished
|
||||
+status: active
|
||||
owner: codex
|
||||
created: "2026-10-04"
|
||||
-updated: "2026-10-06"
|
||||
+updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "efe0ecb2-2f3f-5f49-baee-b90ad1d353ab"
|
||||
@@ -146,3 +146,62 @@
|
||||
primary/railiance01, applied, 0 created / 45 updated (ACTIVITY-WP only) / 0 retired
|
||||
/ 0 refused, 0 task changes, historical UUIDs preserved. Evidence:
|
||||
state-hub docs/evidence/2026-10-06-activity-stale-retirement-rollout.json.
|
||||
+
|
||||
+
|
||||
+## Cluster exact historical source successor, 2026-10-17
|
||||
+
|
||||
+This scoped extension preserves the completed Activity/Custodian work above.
|
||||
+The cluster publisher now refuses three closed RAIL-BS source files containing
|
||||
+15 existing UUID-bound bare task labels. Preserve all three source bytes, IDs,
|
||||
+and UUIDs. No new alias or general syntax exemption is introduced. Evidence and
|
||||
+exact receiver-first proposal: cluster-historical-publication-r1 in the Custodian
|
||||
+NetKingdom change packet. The existing SHR-WP-0001 project driver owns the
|
||||
+Repo Manager / State Hub cross-owner wait; NK-WP-0049-T35 benefits from the
|
||||
+blocked cluster publication. No worker or human gate is woken here.
|
||||
+
|
||||
+## Prepare exact cluster registrar successor
|
||||
+
|
||||
+```task
|
||||
+id: RMGR-WP-0019-T04
|
||||
+status: done
|
||||
+priority: high
|
||||
+```
|
||||
+
|
||||
+Prepared full-file SHA256 bindings for three archives and 15 exact task
|
||||
+occurrences; strict scans still return 15 invalid labels and five collisions.
|
||||
+Unknown/live sources, changed/missing/symlinked histories, UUID drift and
|
||||
+qualified/global collisions refuse before allocation. Candidate tests pass 47;
|
||||
+canonical application remains a separate reviewed step. The read-only current
|
||||
+cluster preview proposes seven ordinary UUID bindings in two files and changes
|
||||
+no bytes. One pre-existing ADHOC field is included explicitly: task
|
||||
+RCLUSTER-WP-ADHOC-2026-07-27-T01 in workplans/ADHOC-2026-07-27.md receives
|
||||
+3e3f39d3-5070-5ba3-97db-19f5f361f264 through managed allocation only.
|
||||
+
|
||||
+## Admit cluster registrar successor only after receiving verification
|
||||
+
|
||||
+```task
|
||||
+id: RMGR-WP-0019-T05
|
||||
+status: wait
|
||||
+priority: high
|
||||
+depends_on: [STATE-WP-0095-T06]
|
||||
+```
|
||||
+
|
||||
+Retain the current guard until exact reviewed receiving source has been delivered
|
||||
+and verified through State Hub's normal owner deployment. Match both manifests,
|
||||
+then integrate reviewed registrar source and verify ordinary allocator preview.
|
||||
+No direct API patch, history rename or bypass substitutes for this gate.
|
||||
+
|
||||
+## Run ordinary cluster owner publication and verify preservation
|
||||
+
|
||||
+```task
|
||||
+id: RMGR-WP-0019-T06
|
||||
+status: wait
|
||||
+priority: high
|
||||
+depends_on: [RMGR-WP-0019-T05]
|
||||
+```
|
||||
+
|
||||
+Only then run normal owner consistency/publication with the reviewed expected
|
||||
+file set: RCLUSTER-WP-0018 and the one-field ADHOC managed backfill, plus ordinary
|
||||
+generated indexes. Preserve all three archived files and all 18 existing
|
||||
+parent/task rows. Return actual receiving/publication receipts to STATE-WP-0095-T07.
|
||||
+Do not infer TLS recovery, credential custody or native acceptance from sync.
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md
|
||||
@@ -0,0 +1,177 @@
|
||||
+---
|
||||
+id: RAIL-BS-WP-0002
|
||||
+type: workplan
|
||||
+title: "k3s and Kubernetes Platform Baseline"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: finished
|
||||
+owner: railiance
|
||||
+topic_slug: railiance
|
||||
+repo_goal_id: "70ab2379-fb9d-4fec-a09d-b2a717e4ace8"
|
||||
+state_hub_workstream_id: "4c63dfc6-9eac-4e79-9f77-8f644ad7147d"
|
||||
+created: "2026-03-09"
|
||||
+updated: "2026-03-10"
|
||||
+completed: "2026-03-10"
|
||||
+---
|
||||
+
|
||||
+> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
+
|
||||
+# k3s and Kubernetes Platform Baseline
|
||||
+
|
||||
+## Goal
|
||||
+
|
||||
+Install k3s, Helm, and the baseline Kubernetes services on the converged
|
||||
+HostEurope node. This workplan picks up exactly where `railiance-hosts`
|
||||
+leaves off: a hardened, verified OS node that is ready for Kubernetes.
|
||||
+
|
||||
+## Pre-condition
|
||||
+
|
||||
+`railiance-infra` converge + Goss verify must pass before any task here
|
||||
+is executed:
|
||||
+
|
||||
+```bash
|
||||
+# In railiance-infra/
|
||||
+make converge
|
||||
+make verify # must exit 0
|
||||
+```
|
||||
+
|
||||
+## Boundary
|
||||
+
|
||||
+This repo owns everything from k3s upward. It must not re-configure items
|
||||
+defined in `railiance-infra/spec/server-baseline.yaml`. See ADR-003:
|
||||
+`railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`.
|
||||
+
|
||||
+**Out of scope here:** platform services (PostgreSQL, storage, identity)
|
||||
+→ `railiance-platform`. Application deployments (Gitea, coulomb services)
|
||||
+→ `railiance-apps`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Tasks
|
||||
+
|
||||
+### T01 — Ansible playbook: install k3s (server mode)
|
||||
+
|
||||
+```task
|
||||
+id: T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "3f042630-eab0-4c6a-9167-e2b28ff20e40"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Harden `ansible/bootstrap.yml` to a production-ready k3s install:
|
||||
+
|
||||
+- Use the official k3s install script pinned to a specific version
|
||||
+ (`INSTALL_K3S_VERSION=v1.35.1+k3s1`)
|
||||
+- `INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=644"`
|
||||
+ (cluster-init enables embedded etcd for future HA expansion)
|
||||
+- Wait for node `Ready` before proceeding:
|
||||
+ ```bash
|
||||
+ k3s kubectl wait node --all --for=condition=Ready --timeout=120s
|
||||
+ ```
|
||||
+- Fetch kubeconfig to the control node as `~/.kube/config-hosteurope`
|
||||
+
|
||||
+**Done when:** `k3s kubectl get nodes` returns `Ready` from both the server
|
||||
+and the control node (via kubeconfig).
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T02 — Helm installation
|
||||
+
|
||||
+```task
|
||||
+id: T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "e8510646-46ed-4697-a345-f3d3009eea78"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Add a task (or a role `roles/helm/`) that:
|
||||
+
|
||||
+1. Downloads the Helm binary (pinned version) to `/usr/local/bin/helm`
|
||||
+2. Verifies the checksum
|
||||
+3. Confirms `helm version` succeeds
|
||||
+
|
||||
+**Done when:** `helm version` succeeds on the HostEurope node.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T03 — Smoke test: k3s + Helm
|
||||
+
|
||||
+```task
|
||||
+id: T03
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "dab2c07f-8aa0-4635-8df6-857e87e93fc5"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Extend `tests/smoke_kube.sh` to assert:
|
||||
+
|
||||
+- `k3s kubectl get nodes` → node in Ready state
|
||||
+- `helm version` exits 0
|
||||
+- CoreDNS pod running in `kube-system`
|
||||
+- Traefik ingress controller pod running (default in k3s)
|
||||
+
|
||||
+Run via:
|
||||
+```bash
|
||||
+ansible-playbook -i ansible/hosts.ini ansible/smoke.yml
|
||||
+```
|
||||
+or directly over SSH if the kubeconfig is available locally.
|
||||
+
|
||||
+**Done when:** all assertions pass and the script exits 0.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T04 — Commit kubeconfig management notes
|
||||
+
|
||||
+```task
|
||||
+id: T04
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "5c3d40e4-239b-488e-9519-6f7a38d2325f"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Document in `docs/kubeconfig.md`:
|
||||
+
|
||||
+- Where the kubeconfig is fetched to (`~/.kube/config-hosteurope`)
|
||||
+- How to merge it into `~/.kube/config`
|
||||
+- How to switch context: `kubectl config use-context default`
|
||||
+- Security note: kubeconfig is gitignored (contains cluster CA + client cert)
|
||||
+
|
||||
+**Done when:** doc written and committed.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T05 — Add `make k3s-install` and `make smoke` targets
|
||||
+
|
||||
+```task
|
||||
+id: T05
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "7f9e0e58-a130-467a-a2d0-b3f2564e496f"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Add to Makefile (create one if none exists):
|
||||
+
|
||||
+```makefile
|
||||
+k3s-install: ## Install k3s and Helm on all inventory hosts
|
||||
+ ansible-playbook -i ansible/hosts.ini ansible/bootstrap.yml
|
||||
+
|
||||
+smoke: ## Run Kubernetes smoke tests
|
||||
+ bash tests/smoke_kube.sh
|
||||
+```
|
||||
+
|
||||
+**Done when:** both targets work and are listed in `make help`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## References
|
||||
+
|
||||
+- Repo goal: `70ab2379-fb9d-4fec-a09d-b2a717e4ace8` (Install k3s and Kubernetes Baseline)
|
||||
+- Domain goal: `6f96c712-60e6-4ea9-ab06-168878eafbce` (Three-Phoenix Secure Kubernetes Infrastructure)
|
||||
+- Pre-condition: railiance-infra WP-0001 (Secure Single-Server Bootstrap) — completed 2026-03-09
|
||||
+- Boundary ADR: `railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`
|
||||
+- k3s releases: https://github.com/k3s-io/k3s/releases
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md
|
||||
@@ -0,0 +1,196 @@
|
||||
+---
|
||||
+id: RAIL-BS-WP-0003
|
||||
+type: workplan
|
||||
+title: "pgpool CrashLoopBackOff on PostgreSQL HA failover — missing secret key"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: finished
|
||||
+owner: tegwick
|
||||
+created: "2026-03-10"
|
||||
+updated: "2026-03-10"
|
||||
+state_hub_workstream_id: "7ee9ee22-1fae-4567-9194-8d70a9e0f45b"
|
||||
+---
|
||||
+
|
||||
+> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
+
|
||||
+# Bug Report: pgpool CrashLoopBackOff on PostgreSQL HA failover
|
||||
+
|
||||
+## Summary
|
||||
+
|
||||
+On 2026-03-10 a PostgreSQL HA failover caused all three postgresql pods to
|
||||
+restart. pgpool — the connection pooler between Gitea and PostgreSQL — then
|
||||
+entered CrashLoopBackOff and produced no logs. As a result Gitea's login
|
||||
+and all write operations hung indefinitely. The root page was still served
|
||||
+(from Valkey cache) which masked the failure.
|
||||
+
|
||||
+The fix was to patch a missing key in a Kubernetes secret. The root cause is
|
||||
+that the `gitea-12.2.0` Helm chart (postgresql-ha subchart v16.2.2) does not
|
||||
+populate the `pgpool-password` key in the `gitea-postgresql-ha-postgresql`
|
||||
+secret, even though the pgpool pod requires it at startup.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Timeline
|
||||
+
|
||||
+| Time (UTC) | Event |
|
||||
+|---|---|
|
||||
+| ~09:45 | postgresql-0, postgresql-2 pods restarted (repmgr failover) |
|
||||
+| ~09:45 | pgpool pod restarted and entered CrashLoopBackOff |
|
||||
+| ~11:00 | User noticed Gitea login hanging; home page still loading |
|
||||
+| ~13:00 | Root cause identified: missing `pgpool-password` secret key |
|
||||
+| ~13:10 | Secret patched; pgpool pod deleted and restarted cleanly |
|
||||
+| ~13:15 | Gitea fully operational |
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Root Cause
|
||||
+
|
||||
+The Bitnami `pgpool` container startup script reads the file
|
||||
+`/opt/bitnami/pgpool/secrets/pgpool-password`, which is mounted from the
|
||||
+`gitea-postgresql-ha-postgresql` Kubernetes Secret via a `subPath` volume
|
||||
+mount. That secret key was never created by the Helm chart, so the file did
|
||||
+not exist. The container exited immediately with no logs.
|
||||
+
|
||||
+The pod had been running for 20 days without a restart, so this gap was
|
||||
+never discovered during initial deployment.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Evidence
|
||||
+
|
||||
+```bash
|
||||
+# Secret was missing the pgpool-password key
|
||||
+sudo k3s kubectl get secret -n default gitea-postgresql-ha-postgresql -o yaml
|
||||
+# data: keys were password, postgres-password, repmgr-password only
|
||||
+# pgpool-password was absent
|
||||
+
|
||||
+# pgpool pod describe showed 824 back-off restarts over 173 minutes
|
||||
+# No logs in either current or --previous output
|
||||
+sudo k3s kubectl logs -n default <pgpool-pod> --previous
|
||||
+# (empty)
|
||||
+
|
||||
+# Gitea process had zero TCP connections to PostgreSQL port 5432
|
||||
+# but many connections to Valkey port 6379
|
||||
+cat /proc/<gitea-pid>/net/tcp | grep 1538 # 1538 = 5432 hex — no results
|
||||
+```
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Immediate Fix Applied
|
||||
+
|
||||
+```bash
|
||||
+# Add the missing key (value = sr-check-password = changeme4 = base64: Y2hhbmdlbWU0)
|
||||
+sudo k3s kubectl patch secret -n default gitea-postgresql-ha-postgresql \
|
||||
+ --type='json' \
|
||||
+ -p='[{"op":"add","path":"/data/pgpool-password","value":"Y2hhbmdlbWU0"}]'
|
||||
+
|
||||
+# Restart pgpool
|
||||
+sudo k3s kubectl delete pod -n default <pgpool-pod-name>
|
||||
+```
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Risk: Fix Will Be Lost on helm upgrade
|
||||
+
|
||||
+The patched secret is managed by Helm (annotation:
|
||||
+`meta.helm.sh/release-name: gitea`). A `helm upgrade` will regenerate the
|
||||
+secret from the chart template, which does not include `pgpool-password`,
|
||||
+and the bug will recur.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Tasks
|
||||
+
|
||||
+### T01 — Add pgpool-password to Helm values
|
||||
+
|
||||
+```task
|
||||
+id: T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc"
|
||||
+```
|
||||
+
|
||||
+Create or update `helm/gitea-values.yaml` (or equivalent) to permanently
|
||||
+include the pgpool-password so it survives `helm upgrade`:
|
||||
+
|
||||
+```yaml
|
||||
+postgresql-ha:
|
||||
+ postgresql:
|
||||
+ pgpoolPassword: <value matching sr-check-password>
|
||||
+```
|
||||
+
|
||||
+**Done when:** `helm upgrade gitea` completes and pgpool starts cleanly
|
||||
+without manual secret patching.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T02 — Add pgpool health check to smoke test
|
||||
+
|
||||
+```task
|
||||
+id: T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "ab166073-30a7-4702-a037-4091e8706e20"
|
||||
+```
|
||||
+
|
||||
+Extend `tests/smoke_kube.sh` to assert:
|
||||
+
|
||||
+```bash
|
||||
+# All postgresql-ha pods Running
|
||||
+kubectl get pods -n default | grep gitea-postgresql-ha | grep -v Running && exit 1
|
||||
+
|
||||
+# pgpool specifically not in CrashLoopBackOff
|
||||
+kubectl get pod -n default -l app.kubernetes.io/component=pgpool \
|
||||
+ -o jsonpath='{.items[0].status.containerStatuses[0].state}' | grep -v crash
|
||||
+```
|
||||
+
|
||||
+**Done when:** the smoke test catches a pgpool failure within 5 minutes.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T03 — Add HA failover test
|
||||
+
|
||||
+```task
|
||||
+id: T03
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "140da396-8e30-4f4d-b88c-c42c0cd46c01"
|
||||
+```
|
||||
+
|
||||
+Create `tests/test_ha_failover.sh` that:
|
||||
+
|
||||
+1. Records Gitea login response time (baseline)
|
||||
+2. Kills the primary PostgreSQL pod: `kubectl delete pod gitea-postgresql-ha-postgresql-0 -n default`
|
||||
+3. Waits for repmgr to promote a replica (max 60s)
|
||||
+4. Asserts Gitea login POST still succeeds within 10s
|
||||
+5. Asserts pgpool pod is Running (not CrashLoopBackOff)
|
||||
+6. Asserts all postgresql pods return to Running
|
||||
+
|
||||
+This test must pass before any PostgreSQL HA deployment is considered done.
|
||||
+
|
||||
+**Done when:** script exits 0 against a live cluster.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T04 — Document the incident in docs/
|
||||
+
|
||||
+```task
|
||||
+id: T04
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d"
|
||||
+```
|
||||
+
|
||||
+Add `docs/incidents/2026-03-10-pgpool-missing-secret.md` with the full
|
||||
+timeline, root cause, and fix, so future operators understand what happened
|
||||
+and how to recover.
|
||||
+
|
||||
+**Done when:** doc committed and linked from `docs/README.md`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## References
|
||||
+
|
||||
+- Bitnami postgresql-ha chart v16.2.2
|
||||
+- Gitea Helm chart v12.2.0
|
||||
+- Related decision: D3 (HA testing policy) in `DECISIONS.md`
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md
|
||||
@@ -0,0 +1,275 @@
|
||||
+---
|
||||
+id: RAIL-BS-WP-0004
|
||||
+type: workplan
|
||||
+title: "Integrated Backup — S2 Kubernetes Runtime Layer"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: finished
|
||||
+owner: tegwick
|
||||
+topic_slug: railiance
|
||||
+state_hub_workstream_id: "7e8b0c20-51eb-40c9-9e3b-85dd380d7625"
|
||||
+created: "2026-02-25"
|
||||
+updated: "2026-03-26"
|
||||
+---
|
||||
+
|
||||
+> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
+
|
||||
+# Integrated Backup — S2 Kubernetes Runtime Layer
|
||||
+
|
||||
+## Goal
|
||||
+
|
||||
+Implement the Q3 (Operability & Resilience) integrated backup for
|
||||
+railiance-cluster (S2). Backs up what S2 owns — the Kubernetes runtime state —
|
||||
+encrypted with age, written to a local directory on the server. No external
|
||||
+dependencies required.
|
||||
+
|
||||
+## Architecture (Decision D4)
|
||||
+
|
||||
+Each railiance repo implements its own backup for what it owns. No central
|
||||
+backup service. See `DECISIONS.md` D4 for full rationale.
|
||||
+
|
||||
+**Standard interface every railiance repo must provide:**
|
||||
+
|
||||
+```bash
|
||||
+make backup # encrypt + write to /opt/backup/railiance/<layer>/
|
||||
+make restore # restore from most recent local backup
|
||||
+```
|
||||
+
|
||||
+Encryption: age, same key pair as SOPS secrets (`.sops.yaml` public key).
|
||||
+Output: `/opt/backup/railiance/cluster/` on the server.
|
||||
+
|
||||
+## What S2 (railiance-cluster) owns and must back up
|
||||
+
|
||||
+| Asset | Why it matters |
|
||||
+|---|---|
|
||||
+| k3s etcd snapshots | Full cluster state — all workloads, configs, secrets |
|
||||
+| Helm release values | Runtime values not in git (any manually applied overrides) |
|
||||
+| kubeconfig | Admin access to the cluster |
|
||||
+
|
||||
+**Not S2's responsibility:**
|
||||
+- Custodian State Hub DB → the-custodian owns this
|
||||
+- Operator workstation config (`.claude/`, `.gitconfig`) → operator's own concern
|
||||
+- Application data (Gitea repos, uploads) → S5 (railiance-apps) owns this
|
||||
+- PostgreSQL data volumes → S3 (railiance-platform) owns this
|
||||
+
|
||||
+## Encryption
|
||||
+
|
||||
+Reuse the age public key from `.sops.yaml`:
|
||||
+
|
||||
+```bash
|
||||
+AGE_PUBLIC_KEY=$(grep 'age:' .sops.yaml | awk '{print $2}')
|
||||
+tar -czf - <assets> | age -r "${AGE_PUBLIC_KEY}" -o backup.tar.gz.age
|
||||
+```
|
||||
+
|
||||
+Decryption requires the private key at `~/.config/sops/age/keys.txt`
|
||||
+(same key used for `sops -d`). No additional key management needed.
|
||||
+
|
||||
+## Extension Point EP-RAIL-005
|
||||
+
|
||||
+Once all five OAS layers implement this interface, the custodian can
|
||||
+orchestrate a full-stack backup with:
|
||||
+
|
||||
+```bash
|
||||
+for repo in railiance-infra railiance-cluster railiance-platform \
|
||||
+ railiance-enablement railiance-apps; do
|
||||
+ make -C ~/$repo backup
|
||||
+done
|
||||
+```
|
||||
+
|
||||
+No special protocol needed — just the standard interface.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Tasks
|
||||
+
|
||||
+### T01 — Define backup directory and encryption wrapper
|
||||
+
|
||||
+```task
|
||||
+id: T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "4526a842-ea31-4874-9231-92ab556cfe7b"
|
||||
+```
|
||||
+
|
||||
+Create `tools/cmd/railiance-backup-s2` (replacing the old `railiance-backup`):
|
||||
+
|
||||
+- Backup dir: `/opt/backup/railiance/cluster/` (create with `mkdir -p`)
|
||||
+- Encrypt each artifact with age using public key from `.sops.yaml`
|
||||
+- Write timestamp-named files: `etcd-<ts>.snap.age`, `helm-values-<ts>.tar.gz.age`, `kubeconfig-<ts>.yaml.age`
|
||||
+- Keep last 7 of each type
|
||||
+- Write `.last-backup` stamp
|
||||
+- Exit 0 on success, non-zero on any failure
|
||||
+- No network required
|
||||
+
|
||||
+Also remove the old `tools/cmd/railiance-backup` (backed up Docker-based
|
||||
+custodian DB — wrong scope, not applicable to this server).
|
||||
+
|
||||
+**Done when:** `make backup` runs on COULOMBCORE without error and files
|
||||
+appear in `/opt/backup/railiance/cluster/`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T02 — Back up k3s state (SQLite hot backup)
|
||||
+
|
||||
+```task
|
||||
+id: T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "a6313e06-1976-46a7-8e31-df4eb2eca880"
|
||||
+```
|
||||
+
|
||||
+k3s has built-in etcd snapshot support:
|
||||
+
|
||||
+```bash
|
||||
+sudo k3s etcd-snapshot save --name railiance-$(date -u +%Y%m%dT%H%M%SZ)
|
||||
+# Default location: /var/lib/rancher/k3s/server/db/snapshots/
|
||||
+```
|
||||
+
|
||||
+Add to the backup script: take a fresh snapshot, encrypt with age,
|
||||
+copy to `/opt/backup/railiance/cluster/`.
|
||||
+
|
||||
+> **Note — verify etcd is in use before implementing:**
|
||||
+> `k3s etcd-snapshot` only works if k3s was started with `--cluster-init`.
|
||||
+> Without it, k3s uses SQLite and this command will fail.
|
||||
+> Verify first: `sudo k3s etcd-snapshot ls 2>&1`
|
||||
+
|
||||
+> **Note — sudo required:** etcd snapshot requires root. See T06 for how
|
||||
+> this is resolved (backup runs under root's crontab).
|
||||
+
|
||||
+**Done when:** backup includes a current etcd snapshot.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T03 — Back up Helm release values
|
||||
+
|
||||
+```task
|
||||
+id: T03
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3"
|
||||
+```
|
||||
+
|
||||
+Capture current runtime Helm values for all releases:
|
||||
+
|
||||
+```bash
|
||||
+KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm list -A -o json | \
|
||||
+ jq -r '.[].name + " " + .namespace' | \
|
||||
+ while read name ns; do
|
||||
+ KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm get values "$name" -n "$ns" -o yaml
|
||||
+ done
|
||||
+```
|
||||
+
|
||||
+Tar and age-encrypt into `helm-values-<ts>.tar.gz.age`.
|
||||
+
|
||||
+> **Note — kubeconfig permissions:** `/etc/rancher/k3s/k3s.yaml` is root-readable
|
||||
+> only by default. The backup script must either run as root (see T06) or k3s
|
||||
+> must be configured with `--write-kubeconfig-mode=644`. Running as root
|
||||
+> (via root crontab) is the chosen approach — no config change needed.
|
||||
+
|
||||
+**Done when:** backup includes a snapshot of all Helm release values.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T04 — Back up kubeconfig
|
||||
+
|
||||
+```task
|
||||
+id: T04
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "08233868-d522-4117-bc4e-6c0f52545665"
|
||||
+```
|
||||
+
|
||||
+Age-encrypt `~/.kube/config-hosteurope` (or `/etc/rancher/k3s/k3s.yaml`)
|
||||
+into `kubeconfig-<ts>.yaml.age` in the backup directory.
|
||||
+
|
||||
+**Done when:** backup includes the encrypted kubeconfig.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T05 — make restore target
|
||||
+
|
||||
+```task
|
||||
+id: T05
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8"
|
||||
+```
|
||||
+
|
||||
+Add `tools/cmd/railiance-restore-s2` that decrypts and lists available
|
||||
+backups, with guided restore for the etcd snapshot case.
|
||||
+
|
||||
+Restore of etcd from snapshot:
|
||||
+```bash
|
||||
+sudo k3s server --cluster-reset \
|
||||
+ --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/<name>
|
||||
+```
|
||||
+
|
||||
+**Done when:** `make restore` prints available backups and a restore guide.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T06 — Install cron job and run restore drill
|
||||
+
|
||||
+```task
|
||||
+id: T06
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "f8e4a094-c367-40eb-b895-da17bc144b07"
|
||||
+```
|
||||
+
|
||||
+#### Solving the sudo problem
|
||||
+
|
||||
+The backup script needs root for two reasons:
|
||||
+- `k3s etcd-snapshot save` requires root
|
||||
+- `/etc/rancher/k3s/k3s.yaml` (kubeconfig) is root-readable only
|
||||
+
|
||||
+**Solution: run the cron under root's crontab.**
|
||||
+
|
||||
+This is the correct pattern for system-level backup jobs. It avoids a
|
||||
+proliferating sudoers whitelist (one entry per command, brittle to maintain)
|
||||
+and matches how tools like `rsnapshot`, `bacula`, and `borgbackup` work in
|
||||
+production. The backup writes to `/opt/backup/` which is root-owned anyway.
|
||||
+
|
||||
+Install the cron as root:
|
||||
+
|
||||
+```bash
|
||||
+sudo crontab -e
|
||||
+# Add:
|
||||
+0 2 * * * make -C /home/tegwick/railiance-cluster backup >> /opt/backup/railiance/cluster/backup.log 2>&1
|
||||
+```
|
||||
+
|
||||
+Note: use the absolute path to the repo — `~` does not expand reliably in
|
||||
+root's crontab unless HOME is set.
|
||||
+
|
||||
+Verify it is installed:
|
||||
+```bash
|
||||
+sudo crontab -l | grep railiance
|
||||
+```
|
||||
+
|
||||
+#### Restore drill
|
||||
+
|
||||
+Once T01–T04 are done, run a decrypt-and-verify drill:
|
||||
+
|
||||
+```bash
|
||||
+# Decrypt the etcd snapshot and verify it is a valid snapshot file
|
||||
+sudo age -d -i ~/.config/sops/age/keys.txt \
|
||||
+ /opt/backup/railiance/cluster/etcd-$(ls /opt/backup/railiance/cluster/etcd-*.snap.age | sort -r | head -1 | xargs basename | sed 's/etcd-//;s/.snap.age//').snap.age \
|
||||
+ | file -
|
||||
+
|
||||
+# Record the drill
|
||||
+echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restore drill OK" \
|
||||
+ >> /opt/backup/railiance/cluster/restore-drill.log
|
||||
+```
|
||||
+
|
||||
+**Done when:** cron installed under root, drill completes without error,
|
||||
+log entry written.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## References
|
||||
+
|
||||
+- Decision D4: Integrated backup per capability (`DECISIONS.md`)
|
||||
+- Decision D2: Nextcloud as optional offsite extension (still valid, not a requirement)
|
||||
+- OAS Q3: Operability & Resilience
|
||||
+- Extension point EP-RAIL-005: Custodian full-stack backup orchestration
|
||||
+- k3s etcd snapshots: https://docs.k3s.io/datastore/backup-restore
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
schema: repo-manager.assistant-provenance.v1
|
||||
cutover_commit: 329af60753b20896f30f098e8f75074652780032
|
||||
known_automation:
|
||||
- name: custodian-sync
|
||||
email: custodian-sync@railiance.local
|
||||
trailer_required: false
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# Example dual-run pilot config (RMGR-WP-0003).
|
||||
# Install: cp config/dual-run.pilot.example.yaml ~/.repo-manager/dual-run.yaml
|
||||
# Env vars still override when set. Rollback: writeback/reconcile false or delete file.
|
||||
|
||||
writeback: true
|
||||
reconcile: true
|
||||
writeback_push: false # set true only if pilot checkouts can push safely
|
||||
pilot_repos:
|
||||
- repo-manager
|
||||
meter_path: ~/.repo-manager/mutation-meter.jsonl
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
schema: repo-manager.fleet-namespace.v1
|
||||
namespace: helixforge
|
||||
scope: HelixForge fleet
|
||||
lineage:
|
||||
kind: root
|
||||
parent: null
|
||||
posture:
|
||||
current: {N: 2, U: 1, R: 1, S: 1}
|
||||
target: {N: 3, U: 2, R: 2, S: 1}
|
||||
declared_by: Bernd Worsch
|
||||
declared: "2026-08-21"
|
||||
decision_ref: RMGR-WP-0005-T03
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
schema: repo-manager.hub-record-authority.v1
|
||||
updated: "2026-08-21"
|
||||
workplan_task: RMGR-WP-0005-T08
|
||||
|
||||
routing:
|
||||
file-derived-key: "repository:<domain_slug>/<repo_slug>"
|
||||
hub-native-key: "hub:<owner>"
|
||||
retired-key: "archive:<record_type>"
|
||||
unknown-record-policy: reject
|
||||
claimed-owner-mismatch-policy: reject
|
||||
|
||||
classes:
|
||||
file-derived:
|
||||
authority: repository
|
||||
offline: write-and-commit-source-file
|
||||
hub-native-append:
|
||||
authority: hub-core
|
||||
offline: append-to-idempotent-outbox
|
||||
hub-native-control:
|
||||
authority: owning-central-hub
|
||||
offline: no-generic-replay
|
||||
derived-cache:
|
||||
authority: upstream-source
|
||||
offline: rebuild-or-refresh
|
||||
retired:
|
||||
authority: archived-provenance
|
||||
offline: reject-writes
|
||||
|
||||
records:
|
||||
agent_messages: {class: hub-native-control, owner: hub-core}
|
||||
capability_catalog: {class: file-derived, owner: repo-manager, transition: retire-direct-db-writes}
|
||||
capability_requests: {class: file-derived, owner: repo-manager, transition: register-entry-files}
|
||||
contributions: {class: file-derived, owner: repo-manager}
|
||||
decisions: {class: file-derived, owner: repo-manager, transition: retire-direct-db-writes}
|
||||
doi_cache: {class: derived-cache, owner: repo-manager}
|
||||
domain_goals: {class: hub-native-control, owner: hub-core}
|
||||
domains: {class: hub-native-control, owner: hub-core}
|
||||
extension_points: {class: file-derived, owner: repo-manager}
|
||||
fabric_graph_edges: {class: derived-cache, owner: fin-hub}
|
||||
fabric_graph_imports: {class: derived-cache, owner: fin-hub}
|
||||
fabric_graph_nodes: {class: derived-cache, owner: fin-hub}
|
||||
intake_notes: {class: file-derived, owner: repo-manager, transition: retire-direct-db-writes}
|
||||
intakes: {class: file-derived, owner: repo-manager, transition: retire-direct-db-writes}
|
||||
interface_changes: {class: hub-native-control, owner: hub-core}
|
||||
legacy_interface_usage_buckets: {class: derived-cache, owner: hub-core}
|
||||
legacy_interfaces: {class: derived-cache, owner: hub-core}
|
||||
managed_repos: {class: file-derived, owner: repo-manager, note: composite-repository-projection}
|
||||
progress_events: {class: hub-native-append, owner: hub-core}
|
||||
repo_goals: {class: file-derived, owner: repo-manager}
|
||||
sbom_entries: {class: derived-cache, owner: repo-manager}
|
||||
sbom_snapshots: {class: derived-cache, owner: repo-manager}
|
||||
service_catalog: {class: hub-native-control, owner: ops-hub}
|
||||
service_cloud: {class: hub-native-control, owner: ops-hub}
|
||||
service_first_party: {class: hub-native-control, owner: ops-hub}
|
||||
service_self_hosted: {class: hub-native-control, owner: ops-hub}
|
||||
service_third_party: {class: hub-native-control, owner: ops-hub}
|
||||
suggestion_notes: {class: retired, owner: archive}
|
||||
suggestion_relevance_bumps: {class: retired, owner: archive}
|
||||
suggestions: {class: retired, owner: archive}
|
||||
tasks: {class: file-derived, owner: repo-manager}
|
||||
td_notes: {class: file-derived, owner: repo-manager}
|
||||
technical_debt: {class: file-derived, owner: repo-manager}
|
||||
token_events: {class: hub-native-append, owner: hub-core}
|
||||
topics: {class: hub-native-control, owner: hub-core, transition: retire-legacy-topic-spine}
|
||||
tpsc_catalog: {class: hub-native-control, owner: ops-hub}
|
||||
tpsc_entries: {class: derived-cache, owner: repo-manager}
|
||||
tpsc_snapshots: {class: derived-cache, owner: repo-manager}
|
||||
workplan_dependencies: {class: file-derived, owner: repo-manager}
|
||||
workplan_launch_requests: {class: hub-native-control, owner: activity-core}
|
||||
workplans: {class: file-derived, owner: repo-manager}
|
||||
write_idempotency_keys: {class: derived-cache, owner: hub-core}
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
schema: repo-manager.repository-registry.v1
|
||||
registry_source: forgejo
|
||||
identity_source: state-hub-primary-migration-2026-09-01
|
||||
repositories:
|
||||
- repository_id: 01631c7a-f612-4799-892e-a96970d06063
|
||||
slug: prj-forgejo-org-refactor
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/prj-forgejo-org-refactor
|
||||
- repository_id: 03e6fe9f-ca84-4be5-ad5d-03c037bf6107
|
||||
slug: evidence-source
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/evidence-source
|
||||
- repository_id: 040b00d5-9c61-4a48-b21e-59c3f6ff4f18
|
||||
slug: tmux-amq
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/tmux-amq
|
||||
- repository_id: 077cf296-3879-4da5-ac97-46cb248c889d
|
||||
slug: timeline-svg
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/timeline-svg
|
||||
- repository_id: 10f1c902-576d-4782-b1b2-643a6083ee36
|
||||
slug: config-atlas
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/config-atlas
|
||||
- repository_id: 1174ca73-c53d-4bb8-bfc3-fdeda13ef706
|
||||
slug: tegwick-control
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/tegwick-control
|
||||
- repository_id: 11c1ff2e-ad0d-4a1a-b649-7da7b98a07da
|
||||
slug: sand-boxer
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/sand-boxer
|
||||
- repository_id: 13094d7a-b978-4aa3-8a1d-0d05b824698d
|
||||
slug: llm-connect
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/llm-connect
|
||||
- repository_id: 14487b12-9a94-4d98-83e5-dcc0b58b650a
|
||||
slug: whitehat-security
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/whitehat-security
|
||||
- repository_id: 146bcec9-59e8-481e-9996-f0b30ab28abb
|
||||
slug: domain-tree
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/domain-tree
|
||||
- repository_id: 14a5657e-f875-4cd4-8801-3c7dcecb11f2
|
||||
slug: vantage-point
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/vantage-point
|
||||
- repository_id: 155fab93-6a76-415e-a57a-7e39b4428985
|
||||
slug: rapp-core-hub
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rapp-core-hub
|
||||
- repository_id: 195e2991-79c3-4c61-981d-5de78f6fe5b8
|
||||
slug: resource-control
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/resource-control
|
||||
- repository_id: 1bf99f56-6e94-4379-a9ea-295a4c181889
|
||||
slug: ops-bridge
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/ops-bridge
|
||||
- repository_id: 1f5876f3-bc96-4f64-a9c8-f2e4578b8416
|
||||
slug: reef-railiance
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/reef-railiance
|
||||
- repository_id: 215c052f-88e7-4b8d-af84-01008cd606d3
|
||||
slug: rapp-tenant-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rapp-tenant-engine
|
||||
- repository_id: 217fe904-2e15-44a8-bdda-e8c6683f46c9
|
||||
slug: test-driver
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/test-driver
|
||||
- repository_id: 29593df6-89a6-4058-9e0b-863e986b86f8
|
||||
slug: policy-nexus
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/policy-nexus
|
||||
- repository_id: 2ad0ed58-4324-47e2-b695-d7128bc623bf
|
||||
slug: binky-control
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/binky-control
|
||||
- repository_id: 2c0de614-e468-4eb6-8157-470649ac8c05
|
||||
slug: railiance-fabric
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/railiance-fabric
|
||||
- repository_id: 2c72f2ad-d940-4a21-a71e-d490bd51cff2
|
||||
slug: markitect-quarkdown
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/markitect-quarkdown
|
||||
- repository_id: 2cb4c14d-6f90-40a7-a9ee-14a663306cb5
|
||||
slug: risk-nexus
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/risk-nexus
|
||||
- repository_id: 2db668cf-6c52-4965-951f-0d5e617de9e3
|
||||
slug: railiance-hosts
|
||||
lifecycle: archived
|
||||
forgejo_repository: coulomb/railiance-hosts
|
||||
- repository_id: 317300f6-a09b-4464-8a0e-798aad5627d0
|
||||
slug: rein-openweights
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rein-openweights
|
||||
- repository_id: 32549b0d-b065-4294-b577-fad643fa38de
|
||||
slug: can-you-assist
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/can-you-assist
|
||||
- repository_id: 33b4936f-aadd-42e2-a320-33c524a07e1d
|
||||
slug: ralph-workplan
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/ralph-workplan
|
||||
- repository_id: 3786363f-94a5-43a0-889d-8e85993b2783
|
||||
slug: testdrive-jsui
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/testdrive-jsui
|
||||
- repository_id: 39b9170f-25d2-4728-bb68-bdd4791534ea
|
||||
slug: ground-game
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/ground-game
|
||||
- repository_id: 3ce9ef37-9672-4096-9d28-5ef77e450c07
|
||||
slug: email-connect
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/email-connect
|
||||
- repository_id: 3d070446-80e0-4d51-a802-d037b8ac273a
|
||||
slug: rapp-openbao
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rapp-openbao
|
||||
- repository_id: 423794e3-a8a4-49f3-9f07-f743e10d5f67
|
||||
slug: binect-js
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/binect-js
|
||||
- repository_id: 4296e38c-4eb4-4710-8e35-fb37009add0b
|
||||
slug: activity-core
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/activity-core
|
||||
- repository_id: 42f80fe5-e5bb-4765-8e4b-e369517fb36a
|
||||
slug: railiance-bootstrap
|
||||
lifecycle: archived
|
||||
forgejo_repository: coulomb/railiance-bootstrap
|
||||
- repository_id: 439a69fc-084a-4074-88fe-c99de627deeb
|
||||
slug: disaster-control
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/disaster-control
|
||||
- repository_id: 4589488d-625b-4ffe-b8f0-48744d7b1498
|
||||
slug: net-kingdom
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/net-kingdom
|
||||
- repository_id: 46e9c85b-057d-41e0-9382-851bebcf2cc1
|
||||
slug: rapp-qonto
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rapp-qonto
|
||||
- repository_id: 47f50756-aaf6-4897-9bc7-097fcd9daf46
|
||||
slug: python-snake
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/python-snake
|
||||
- repository_id: 485187c0-4fad-42f7-984c-5e317a66c5de
|
||||
slug: railiance-infra
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/railiance-infra
|
||||
- repository_id: 487f321e-2e53-43d4-90a6-af421e438313
|
||||
slug: info-tech-canon
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/info-tech-canon
|
||||
- repository_id: 4a887c44-e301-4c1e-a4fe-1b4a842d8777
|
||||
slug: human-resources
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/human-resources
|
||||
- repository_id: 4fb4a5f3-08f9-49ba-958b-414adc565168
|
||||
slug: feature-control
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/feature-control
|
||||
- repository_id: 5115e0c5-009f-4168-b155-9943fe2ab9a7
|
||||
slug: railiance-platform
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/railiance-platform
|
||||
- repository_id: 56ae522c-b47e-4748-8239-f61b332fe69d
|
||||
slug: the-custodian
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/the-custodian
|
||||
- repository_id: 5bac09ed-a1c9-42fb-bd16-75c8f9e01a1a
|
||||
slug: glas-harness
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/glas-harness
|
||||
- repository_id: 5dd1708d-96e3-4737-96be-05485548ab04
|
||||
slug: prj-state-hub-retirement
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/prj-state-hub-retirement
|
||||
- repository_id: 60f2d8b6-f3fa-4bb0-b316-89de9b2a7d79
|
||||
slug: tele-mcp
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/tele-mcp
|
||||
- repository_id: 69b69830-af7f-496f-9af2-cbc23a83cd42
|
||||
slug: ihp-railiance-probe
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/ihp-railiance-probe
|
||||
- repository_id: 6b6e91cd-160f-4e62-9478-80d7588a35dd
|
||||
slug: kings-guard
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/kings-guard
|
||||
- repository_id: 7007e599-6f2a-4fc6-b6c7-a86b6e9ccfaf
|
||||
slug: reuse-surface
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/reuse-surface
|
||||
- repository_id: 719f49a6-63a0-47d4-9a9c-ea515be17d5a
|
||||
slug: rapp-issue-core
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rapp-issue-core
|
||||
- repository_id: 73be5a8e-8503-447f-87cb-de8c2c8c0b9e
|
||||
slug: rein-aharness
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rein-aharness
|
||||
- repository_id: 74df727e-a756-42de-b776-11928b03bd1f
|
||||
slug: ops-warden
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/ops-warden
|
||||
- repository_id: 75d31180-acf5-4d47-aea8-2a5b1e71e6a9
|
||||
slug: marki-docx
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/marki-docx
|
||||
- repository_id: 761e6ac3-6483-4b7f-ac7a-5e5741a01118
|
||||
slug: user-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/user-engine
|
||||
- repository_id: 76af2d82-9a4b-4797-b542-3f4f5545b6cc
|
||||
slug: whynot-control
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/whynot-control
|
||||
- repository_id: 7945cc60-f228-4baa-879d-5aa515d004e3
|
||||
slug: qonto-assistant
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/qonto-assistant
|
||||
- repository_id: 7b46d0dd-b261-4b8f-82c6-fa26f24e42a0
|
||||
slug: sbom-nexus
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/sbom-nexus
|
||||
- repository_id: 80a9b0c4-f654-47fe-9721-33fc0558687e
|
||||
slug: hub-core
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/hub-core
|
||||
- repository_id: 8142c713-7a09-4703-9771-0b9d4238fe33
|
||||
slug: citation-work
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/citation-work
|
||||
- repository_id: 87f43c8e-01bd-465a-96ce-c0eee2ae7bcf
|
||||
slug: markitect-filter
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/markitect-filter
|
||||
- repository_id: 8a99bb74-1ec0-4478-ac70-35a7cddb0e3c
|
||||
slug: key-cape
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/key-cape
|
||||
- repository_id: 8c82baea-bb40-435d-ac42-ec7a7c20dbb8
|
||||
slug: commerce-canon
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/commerce-canon
|
||||
- repository_id: 8df9b401-f2e4-4d12-b5ba-7fc191d4e606
|
||||
slug: rapp-sbom-nexus
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rapp-sbom-nexus
|
||||
- repository_id: 8e9ffb56-7abc-47b5-84b2-5ad392b37dcd
|
||||
slug: state-hub
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/state-hub
|
||||
- repository_id: 90918b55-6347-4e57-8086-db7b0454f73d
|
||||
slug: guide-board
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/guide-board
|
||||
- repository_id: 90fc8c09-30e7-439d-a873-779bd2cd5ac5
|
||||
slug: coordination-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/coordination-engine
|
||||
- repository_id: 95d05a4d-e276-4eea-a767-c78d3ac4b320
|
||||
slug: rail-knative
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rail-knative
|
||||
- repository_id: 9b69c35b-f3e7-490c-bb9e-d78f96992513
|
||||
slug: polycode-sim
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/polycode-sim
|
||||
- repository_id: 9cfdd641-0c27-436f-ad3b-5d41a1353fcf
|
||||
slug: rapp-user-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rapp-user-engine
|
||||
- repository_id: 9d7b100b-1538-483e-a873-cf70980c59e3
|
||||
slug: coulomb-loop
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/coulomb-loop
|
||||
- repository_id: a4c297f2-b38a-42d3-b1a5-ae4b29640276
|
||||
slug: markitect-main
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/markitect-main
|
||||
- repository_id: a4cac053-048b-453b-93aa-942260bd4e09
|
||||
slug: evidence-anchor
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/evidence-anchor
|
||||
- repository_id: a53a75fb-e9fa-41d9-8a96-12a4b924dc63
|
||||
slug: whynot-design
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/whynot-design
|
||||
- repository_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
|
||||
slug: citation-evidence
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/citation-evidence
|
||||
- repository_id: a7cea934-e38c-467c-b2ea-64fd3424a845
|
||||
slug: kontextual-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/kontextual-engine
|
||||
- repository_id: a9916da2-b4dd-4e12-8a30-47e47f6ed298
|
||||
slug: binect-chrome
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/binect-chrome
|
||||
- repository_id: a9d105c6-10fa-4cf9-8bc1-e248476698d3
|
||||
slug: repo-manager
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/repo-manager
|
||||
- repository_id: aa497d2f-d88d-4065-9ab2-e251df07ead4
|
||||
slug: railiance-master
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/railiance-master
|
||||
- repository_id: b1321f79-ccc7-4b0a-84bf-c7b9825ca427
|
||||
slug: reef-storage
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/reef-storage
|
||||
- repository_id: b51b2b12-dc53-4423-8d3c-78c96eee0362
|
||||
slug: shard-wiki
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/shard-wiki
|
||||
- repository_id: b6b8dacd-4100-475a-8390-c326e1cebdec
|
||||
slug: open-cmis-tck
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/open-cmis-tck
|
||||
- repository_id: b7add1b9-b90f-4baf-acac-2f5ce3037b5f
|
||||
slug: repo-seed
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/repo-seed
|
||||
- repository_id: b7b3e7cc-cdde-4430-be43-e82343fdf1b0
|
||||
slug: infospace-bench
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/infospace-bench
|
||||
- repository_id: b9066ab1-c6fe-409b-9145-64ba374654db
|
||||
slug: ops-mason
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/ops-mason
|
||||
- repository_id: b96c952c-9993-4941-b75a-fe76eb7b2a57
|
||||
slug: citation-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/citation-engine
|
||||
- repository_id: ba599cd4-e8aa-430b-bbbe-e49f88536d70
|
||||
slug: markitect-tool
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/markitect-tool
|
||||
- repository_id: bc978be9-ca72-42bb-a451-d4737b779c5b
|
||||
slug: railiance-enablement
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/railiance-enablement
|
||||
- repository_id: c186e743-9ebb-425a-aecc-7e3ea7cdc2a5
|
||||
slug: railiance-telemetry
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/railiance-telemetry
|
||||
- repository_id: c29eab12-d8b8-46b3-851b-0e23f78f91f3
|
||||
slug: kaizen-agentic
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/kaizen-agentic
|
||||
- repository_id: c3fd0dd2-a0de-415c-8e3a-e37406f4b8f8
|
||||
slug: railiance-cluster
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/railiance-cluster
|
||||
- repository_id: c7a3dc03-d837-4577-a107-a190d6b00705
|
||||
slug: helix-forge
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/helix-forge
|
||||
- repository_id: c96dd8b3-3d15-4c50-8daf-0ec6a2c56ddf
|
||||
slug: ops-hub
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/ops-hub
|
||||
- repository_id: cb06310e-4381-428b-b8f3-d3ea8ac2f70d
|
||||
slug: railiance-apps
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/railiance-apps
|
||||
- repository_id: ce3d5e76-ee5c-40dc-acc8-c04e63a0e406
|
||||
slug: secrets-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/secrets-engine
|
||||
- repository_id: d166babe-c3f0-4e46-8091-a2c44ab557fb
|
||||
slug: direkt-vermittlung-de
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/direkt-vermittlung-de
|
||||
- repository_id: d3581e63-4002-4c83-8c03-93547823c8c7
|
||||
slug: freedom-intelligence
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/freedom-intelligence
|
||||
- repository_id: d416fd6f-3c9d-4ba7-a5ca-f7543940ee6a
|
||||
slug: open-reuse
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/open-reuse
|
||||
- repository_id: d56e0504-044d-4021-ae43-e9d706dbd087
|
||||
slug: phase-memory
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/phase-memory
|
||||
- repository_id: d5c398ad-93a6-479c-bd5e-6078e717c819
|
||||
slug: rapp-policy-nexus
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rapp-policy-nexus
|
||||
- repository_id: d63ab9fa-9bf2-4014-a5e8-d186c417552e
|
||||
slug: target-revenue
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/target-revenue
|
||||
- repository_id: d70d228e-841d-42d9-a7e9-5d978b4008d8
|
||||
slug: tenant-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/tenant-engine
|
||||
- repository_id: d7c12587-e70c-4d06-8843-e582c0d683c3
|
||||
slug: adaptive-pricing
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/adaptive-pricing
|
||||
- repository_id: da8ecb37-e1bf-4260-b853-beb40ac9235b
|
||||
slug: soul-frame
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/soul-frame
|
||||
- repository_id: dc1866c7-08af-4117-8684-057331be20b5
|
||||
slug: rail-kubernetes
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rail-kubernetes
|
||||
- repository_id: dc9e7e92-2c8c-4cec-9021-9182c46ab097
|
||||
slug: agentic-resources
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/agentic-resources
|
||||
- repository_id: de749ff1-a4a4-42f5-8ed4-6b8c05b18bd9
|
||||
slug: repo-scoping
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/repo-scoping
|
||||
- repository_id: e1575e7c-e1b0-4b46-98be-78bd49e06318
|
||||
slug: artifact-store
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/artifact-store
|
||||
- repository_id: e4614b15-7526-4787-bd6f-74aa3cf24c3d
|
||||
slug: rapp-postgres
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/rapp-postgres
|
||||
- repository_id: e51fd6df-525e-457c-9ec0-924832b491bc
|
||||
slug: issue-core
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/issue-core
|
||||
- repository_id: ea264d14-723b-48d9-b330-1e3c929934ec
|
||||
slug: prj-unattended-progress-company
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/prj-unattended-progress-company
|
||||
- repository_id: ea797759-b691-4b5a-910e-3d77f19df0da
|
||||
slug: gate-house
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/gate-house
|
||||
- repository_id: ec60633d-b055-43b7-a83e-7e0e9734ede1
|
||||
slug: approval-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/approval-engine
|
||||
- repository_id: ec840a06-4e33-4fdc-9762-da7a5beec254
|
||||
slug: prj-canon-federation
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/prj-canon-federation
|
||||
- repository_id: edffc8b6-0360-471e-a6b1-df56bde1a27e
|
||||
slug: railiance-forge
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/railiance-forge
|
||||
- repository_id: f14be058-a339-4357-a3ff-c8326f7748a7
|
||||
slug: evidence-binder
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/evidence-binder
|
||||
- repository_id: f4038d32-948d-49e2-9b84-4fd48109100f
|
||||
slug: core-hub
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/core-hub
|
||||
- repository_id: f5d1db1e-e6f7-4299-a496-12dbc5e73b18
|
||||
slug: clay-borg
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/clay-borg
|
||||
- repository_id: fbf4e172-966a-4ec8-b135-2bff8951c550
|
||||
slug: fin-hub
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/fin-hub
|
||||
- repository_id: fc3296df-e3e0-49a4-b022-4701f3c71f9b
|
||||
slug: coulomb-social
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/coulomb-social
|
||||
- repository_id: fda8ad85-a7d7-4055-8f21-902a533e59df
|
||||
slug: access-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/access-engine
|
||||
- repository_id: ff057178-b3ca-48f4-b7b5-2cda570ed12f
|
||||
slug: zone-engine
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/zone-engine
|
||||
- repository_id: ff7b0754-9d54-4abf-a752-d342870f5993
|
||||
slug: vergabe-teilnahme
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/vergabe-teilnahme
|
||||
- repository_id: ffb264e4-5c5b-4c18-9c6f-df6dd5f1bb05
|
||||
slug: audit-core
|
||||
lifecycle: active
|
||||
forgejo_repository: coulomb/audit-core
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
schema: repo-manager.state-hub-access.v1
|
||||
updated: "2026-08-28"
|
||||
workplan_task: CUST-WP-0067-T07
|
||||
notes: >
|
||||
Authoritative State Hub access map for agent-facing AGENTS.md tables.
|
||||
Edit this file, then `rmgr scaffold --path <repo> --refresh-hub-access`.
|
||||
Local 127.0.0.1:8000 is central via ops-bridge state-hub-primary, not a cache.
|
||||
|
||||
endpoints:
|
||||
- context: Local workstation
|
||||
url: http://127.0.0.1:8000
|
||||
- context: Remote (railiance01, in-cluster)
|
||||
url: http://10.43.68.154:8000
|
||||
- context: Optional local edge relay
|
||||
url: http://127.0.0.1:18080
|
||||
|
||||
retired:
|
||||
- url: http://127.0.0.1:18000
|
||||
reason: reverse tunnel back to the workstation; the hub is in-cluster on railiance01
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# Implementation snapshot of the Custodian work-record kind registry.
|
||||
# Canonical source:
|
||||
# the-custodian/canon/standards/work-record-types.yaml (version 0.1)
|
||||
version: "0.1"
|
||||
status: active
|
||||
kinds:
|
||||
- kind: workplan
|
||||
id_patterns:
|
||||
- "^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-WP-[0-9]{4}$"
|
||||
- "^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-WP-ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
||||
legacy_patterns:
|
||||
- pattern: "^CUST-WP-0000b$"
|
||||
source: Custodian historical workplan suffix retained by source and hub
|
||||
grandfathered: true
|
||||
- pattern: "^CUST-WP-0000c$"
|
||||
source: Custodian archived historical workplan suffix retained by source and hub
|
||||
grandfathered: true
|
||||
- pattern: "^CUST-WP-0010b$"
|
||||
source: Custodian historical workplan suffix retained by source and hub
|
||||
grandfathered: true
|
||||
- pattern: "^CUST-WP-(0017|0053)-LEGACY$"
|
||||
source: Custodian archived legacy workplan identity retained by source and hub
|
||||
grandfathered: true
|
||||
- pattern: "^ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
||||
source: daily ad-hoc containers created before repository-qualified ad-hoc ids
|
||||
grandfathered: true
|
||||
- pattern: "^MASON-0001$"
|
||||
source: ops-mason bootstrap workplan created before WP infix convention
|
||||
grandfathered: true
|
||||
- kind: task
|
||||
id_patterns:
|
||||
- "^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-WP-[0-9]{4}-T[0-9]{2,3}$"
|
||||
- "^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-WP-ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}-T[0-9]{2}$"
|
||||
legacy_patterns:
|
||||
- pattern: "^ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}-T[0-9]{2}$"
|
||||
source: daily ad-hoc tasks created before repository-qualified ad-hoc ids
|
||||
grandfathered: true
|
||||
- pattern: "^[A-Z]+-WP-[0-9]{4}-T[0-9]$"
|
||||
source: early single-digit task suffixes (pre task-block convention)
|
||||
grandfathered: true
|
||||
- pattern: "^[A-Z]+-WP-[0-9]{4}-LEGACY-T[0-9]{2}$"
|
||||
source: archived-workplan legacy task renumbering
|
||||
grandfathered: true
|
||||
- pattern: "^MASON-0001-T[0-9]{2,3}$"
|
||||
source: ops-mason bootstrap tasks created before WP infix convention
|
||||
grandfathered: true
|
||||
- pattern: "^CUST-WP-(?:0000c-T0[1-4]|0010b-T0[1-2])$"
|
||||
source: Custodian historical task identity retained by source and hub
|
||||
grandfathered: true
|
||||
scoped_legacy_patterns:
|
||||
- pattern: "^(?:T01|T02|T03|T04|T05)$"
|
||||
parent_pattern: "^CUST-WP-0006$"
|
||||
source: Custodian historical task blocks with parent-qualified UUID bindings
|
||||
grandfathered: true
|
||||
- pattern: "^(?:T01|T02|T03|T04|T05|T06|T07|T08|T09|T10|T11|T12|T13|T14|T15|T16|T17)$"
|
||||
parent_pattern: "^CUST-WP-0007$"
|
||||
source: Custodian historical task blocks with parent-qualified UUID bindings
|
||||
grandfathered: true
|
||||
- pattern: "^(?:T01|T02|T03|T04|T05|T06|T07|T08|T09)$"
|
||||
parent_pattern: "^CUST-WP-0030$"
|
||||
source: Custodian historical task blocks with parent-qualified UUID bindings
|
||||
grandfathered: true
|
||||
- pattern: "^(?:T01|T02|T03|T04|T05|T06|T07)$"
|
||||
parent_pattern: "^CUST-WP-0031$"
|
||||
source: Custodian historical task blocks with parent-qualified UUID bindings
|
||||
grandfathered: true
|
||||
- kind: intake
|
||||
id_patterns:
|
||||
- "^[A-Z]+-IN-[0-9]{4}$"
|
||||
legacy_patterns:
|
||||
- pattern: "^AWQ-[0-9]{3}$"
|
||||
source: binky-control AutopilotWorkQueue
|
||||
grandfathered: true
|
||||
- kind: decision
|
||||
id_patterns:
|
||||
- "^[A-Z]+-DEC-[0-9]{4}-[0-9]{3}$"
|
||||
legacy_patterns:
|
||||
- pattern: "^DEC-[0-9]{4}-[0-9]{3}$"
|
||||
source: binky-control DecisionQueue
|
||||
grandfathered: true
|
||||
identity_reconciliation:
|
||||
repeated_same_id_same_uuid: duplicate_source_occurrence
|
||||
repeated_same_id_different_uuid: identity_collision
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# RMGR-WP-0004-T08 / ADR-007 decision 1
|
||||
# One prefix, one repository. Prefixes derive from repo or project identity,
|
||||
# never from a flavor marker or family name. Detection only — historical
|
||||
# collisions stay until ADR-007 § Migration is ruled.
|
||||
|
||||
standard: workplan-prefix-ownership
|
||||
version: "0.1"
|
||||
canon:
|
||||
- the-custodian/canon/architecture/adr-007-workplan-identity-and-repo-worker-topology.md
|
||||
- the-custodian/canon/standards/project-repository-flavor_v0.1.md
|
||||
|
||||
retired:
|
||||
- prefix: NET-WP
|
||||
reason: second prefix in net-kingdom; NK-WP is the repository's form (229 ids vs 49)
|
||||
successors: [NK-WP]
|
||||
- prefix: PRJ-WP
|
||||
reason: flavor marker; never a valid prefix
|
||||
- prefix: RAIL-BS-WP
|
||||
reason: shared family name, not a repository
|
||||
successors: [RCLUSTER-WP, RBS-WP]
|
||||
- prefix: RAILIANCE-WP
|
||||
reason: family name, not a repository
|
||||
successors: [RPF-WP, RAPPS-WP, RFORGE-WP, RTELE-WP]
|
||||
|
||||
owners:
|
||||
RMGR-WP: repo-manager
|
||||
CUST-WP: the-custodian
|
||||
STATE-WP: state-hub
|
||||
CFED-WP: prj-canon-federation
|
||||
SHR-WP: prj-state-hub-retirement
|
||||
RCLUSTER-WP: railiance-cluster
|
||||
RBS-WP: railiance-bootstrap
|
||||
RPF-WP: railiance-platform
|
||||
RAPPS-WP: railiance-apps
|
||||
RFORGE-WP: railiance-forge
|
||||
RTELE-WP: railiance-telemetry
|
||||
RMASTER-WP: railiance-master
|
||||
RCLK-WP: railiance-clock
|
||||
RAIL-K8S-WP: rail-kubernetes
|
||||
RAPP-OPENBAO-WP: rapp-openbao
|
||||
RAPP-USER-ENGINE-WP: rapp-user-engine
|
||||
RAPP-TENANT-ENGINE-WP: rapp-tenant-engine
|
||||
RAPP-POLICY-NEXUS-WP: rapp-policy-nexus
|
||||
USER-WP: user-engine
|
||||
TEN-WP: tenant-engine
|
||||
|
||||
ACTIVITY-WP: activity-core
|
||||
ADAPTIVE-WP: adaptive-pricing
|
||||
AGENTIC-WP: agentic-resources
|
||||
GROUND-WP: ground-game
|
||||
ITC-WP: info-tech-canon
|
||||
KAIZEN-WP: kaizen-agentic
|
||||
LLM-WP: llm-connect
|
||||
NK-WP: net-kingdom
|
||||
BRIDGE-WP: ops-bridge
|
||||
WARDEN-WP: ops-warden
|
||||
QONTO-WP: qonto-assistant
|
||||
RAIL-HO-WP: railiance-infra
|
||||
SECRETS-WP: secrets-engine
|
||||
TAMQ-WP: tmux-amq
|
||||
RAIL-FAB-WP: railiance-fabric
|
||||
WHYNOT-WP: whynot-design
|
||||
OPS-WP: ops-hub
|
||||
WNCTL-WP: whynot-control
|
||||
CORE-WP: core-hub
|
||||
DISCTL-WP: disaster-control
|
||||
REPO-WP: repo-seed
|
||||
historical_collisions:
|
||||
- id: RAILIANCE-WP-0015
|
||||
repos: [railiance-platform, railiance-apps]
|
||||
- id: RAILIANCE-WP-0016
|
||||
repos: [railiance-platform, railiance-apps]
|
||||
- id: CUST-WP-0000
|
||||
repos: [the-custodian]
|
||||
- id: CUST-WP-0010
|
||||
repos: [the-custodian]
|
||||
- id: CUST-WP-0045
|
||||
repos: [the-custodian]
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
---
|
||||
id: RAIL-BS-WP-0002
|
||||
type: workplan
|
||||
title: "k3s and Kubernetes Platform Baseline"
|
||||
domain: financials
|
||||
repo: railiance-cluster
|
||||
status: finished
|
||||
owner: railiance
|
||||
topic_slug: railiance
|
||||
repo_goal_id: "70ab2379-fb9d-4fec-a09d-b2a717e4ace8"
|
||||
state_hub_workstream_id: "4c63dfc6-9eac-4e79-9f77-8f644ad7147d"
|
||||
created: "2026-03-09"
|
||||
updated: "2026-03-10"
|
||||
completed: "2026-03-10"
|
||||
---
|
||||
|
||||
> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
|
||||
# k3s and Kubernetes Platform Baseline
|
||||
|
||||
## Goal
|
||||
|
||||
Install k3s, Helm, and the baseline Kubernetes services on the converged
|
||||
HostEurope node. This workplan picks up exactly where `railiance-hosts`
|
||||
leaves off: a hardened, verified OS node that is ready for Kubernetes.
|
||||
|
||||
## Pre-condition
|
||||
|
||||
`railiance-infra` converge + Goss verify must pass before any task here
|
||||
is executed:
|
||||
|
||||
```bash
|
||||
# In railiance-infra/
|
||||
make converge
|
||||
make verify # must exit 0
|
||||
```
|
||||
|
||||
## Boundary
|
||||
|
||||
This repo owns everything from k3s upward. It must not re-configure items
|
||||
defined in `railiance-infra/spec/server-baseline.yaml`. See ADR-003:
|
||||
`railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`.
|
||||
|
||||
**Out of scope here:** platform services (PostgreSQL, storage, identity)
|
||||
→ `railiance-platform`. Application deployments (Gitea, coulomb services)
|
||||
→ `railiance-apps`.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — Ansible playbook: install k3s (server mode)
|
||||
|
||||
```task
|
||||
id: T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "3f042630-eab0-4c6a-9167-e2b28ff20e40"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Harden `ansible/bootstrap.yml` to a production-ready k3s install:
|
||||
|
||||
- Use the official k3s install script pinned to a specific version
|
||||
(`INSTALL_K3S_VERSION=v1.35.1+k3s1`)
|
||||
- `INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=644"`
|
||||
(cluster-init enables embedded etcd for future HA expansion)
|
||||
- Wait for node `Ready` before proceeding:
|
||||
```bash
|
||||
k3s kubectl wait node --all --for=condition=Ready --timeout=120s
|
||||
```
|
||||
- Fetch kubeconfig to the control node as `~/.kube/config-hosteurope`
|
||||
|
||||
**Done when:** `k3s kubectl get nodes` returns `Ready` from both the server
|
||||
and the control node (via kubeconfig).
|
||||
|
||||
---
|
||||
|
||||
### T02 — Helm installation
|
||||
|
||||
```task
|
||||
id: T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e8510646-46ed-4697-a345-f3d3009eea78"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Add a task (or a role `roles/helm/`) that:
|
||||
|
||||
1. Downloads the Helm binary (pinned version) to `/usr/local/bin/helm`
|
||||
2. Verifies the checksum
|
||||
3. Confirms `helm version` succeeds
|
||||
|
||||
**Done when:** `helm version` succeeds on the HostEurope node.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Smoke test: k3s + Helm
|
||||
|
||||
```task
|
||||
id: T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "dab2c07f-8aa0-4635-8df6-857e87e93fc5"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Extend `tests/smoke_kube.sh` to assert:
|
||||
|
||||
- `k3s kubectl get nodes` → node in Ready state
|
||||
- `helm version` exits 0
|
||||
- CoreDNS pod running in `kube-system`
|
||||
- Traefik ingress controller pod running (default in k3s)
|
||||
|
||||
Run via:
|
||||
```bash
|
||||
ansible-playbook -i ansible/hosts.ini ansible/smoke.yml
|
||||
```
|
||||
or directly over SSH if the kubeconfig is available locally.
|
||||
|
||||
**Done when:** all assertions pass and the script exits 0.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Commit kubeconfig management notes
|
||||
|
||||
```task
|
||||
id: T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "5c3d40e4-239b-488e-9519-6f7a38d2325f"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Document in `docs/kubeconfig.md`:
|
||||
|
||||
- Where the kubeconfig is fetched to (`~/.kube/config-hosteurope`)
|
||||
- How to merge it into `~/.kube/config`
|
||||
- How to switch context: `kubectl config use-context default`
|
||||
- Security note: kubeconfig is gitignored (contains cluster CA + client cert)
|
||||
|
||||
**Done when:** doc written and committed.
|
||||
|
||||
---
|
||||
|
||||
### T05 — Add `make k3s-install` and `make smoke` targets
|
||||
|
||||
```task
|
||||
id: T05
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "7f9e0e58-a130-467a-a2d0-b3f2564e496f"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Add to Makefile (create one if none exists):
|
||||
|
||||
```makefile
|
||||
k3s-install: ## Install k3s and Helm on all inventory hosts
|
||||
ansible-playbook -i ansible/hosts.ini ansible/bootstrap.yml
|
||||
|
||||
smoke: ## Run Kubernetes smoke tests
|
||||
bash tests/smoke_kube.sh
|
||||
```
|
||||
|
||||
**Done when:** both targets work and are listed in `make help`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Repo goal: `70ab2379-fb9d-4fec-a09d-b2a717e4ace8` (Install k3s and Kubernetes Baseline)
|
||||
- Domain goal: `6f96c712-60e6-4ea9-ab06-168878eafbce` (Three-Phoenix Secure Kubernetes Infrastructure)
|
||||
- Pre-condition: railiance-infra WP-0001 (Secure Single-Server Bootstrap) — completed 2026-03-09
|
||||
- Boundary ADR: `railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`
|
||||
- k3s releases: https://github.com/k3s-io/k3s/releases
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
---
|
||||
id: RAIL-BS-WP-0003
|
||||
type: workplan
|
||||
title: "pgpool CrashLoopBackOff on PostgreSQL HA failover — missing secret key"
|
||||
domain: financials
|
||||
repo: railiance-cluster
|
||||
status: finished
|
||||
owner: tegwick
|
||||
created: "2026-03-10"
|
||||
updated: "2026-03-10"
|
||||
state_hub_workstream_id: "7ee9ee22-1fae-4567-9194-8d70a9e0f45b"
|
||||
---
|
||||
|
||||
> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
|
||||
# Bug Report: pgpool CrashLoopBackOff on PostgreSQL HA failover
|
||||
|
||||
## Summary
|
||||
|
||||
On 2026-03-10 a PostgreSQL HA failover caused all three postgresql pods to
|
||||
restart. pgpool — the connection pooler between Gitea and PostgreSQL — then
|
||||
entered CrashLoopBackOff and produced no logs. As a result Gitea's login
|
||||
and all write operations hung indefinitely. The root page was still served
|
||||
(from Valkey cache) which masked the failure.
|
||||
|
||||
The fix was to patch a missing key in a Kubernetes secret. The root cause is
|
||||
that the `gitea-12.2.0` Helm chart (postgresql-ha subchart v16.2.2) does not
|
||||
populate the `pgpool-password` key in the `gitea-postgresql-ha-postgresql`
|
||||
secret, even though the pgpool pod requires it at startup.
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
| Time (UTC) | Event |
|
||||
|---|---|
|
||||
| ~09:45 | postgresql-0, postgresql-2 pods restarted (repmgr failover) |
|
||||
| ~09:45 | pgpool pod restarted and entered CrashLoopBackOff |
|
||||
| ~11:00 | User noticed Gitea login hanging; home page still loading |
|
||||
| ~13:00 | Root cause identified: missing `pgpool-password` secret key |
|
||||
| ~13:10 | Secret patched; pgpool pod deleted and restarted cleanly |
|
||||
| ~13:15 | Gitea fully operational |
|
||||
|
||||
---
|
||||
|
||||
## Root Cause
|
||||
|
||||
The Bitnami `pgpool` container startup script reads the file
|
||||
`/opt/bitnami/pgpool/secrets/pgpool-password`, which is mounted from the
|
||||
`gitea-postgresql-ha-postgresql` Kubernetes Secret via a `subPath` volume
|
||||
mount. That secret key was never created by the Helm chart, so the file did
|
||||
not exist. The container exited immediately with no logs.
|
||||
|
||||
The pod had been running for 20 days without a restart, so this gap was
|
||||
never discovered during initial deployment.
|
||||
|
||||
---
|
||||
|
||||
## Evidence
|
||||
|
||||
```bash
|
||||
# Secret was missing the pgpool-password key
|
||||
sudo k3s kubectl get secret -n default gitea-postgresql-ha-postgresql -o yaml
|
||||
# data: keys were password, postgres-password, repmgr-password only
|
||||
# pgpool-password was absent
|
||||
|
||||
# pgpool pod describe showed 824 back-off restarts over 173 minutes
|
||||
# No logs in either current or --previous output
|
||||
sudo k3s kubectl logs -n default <pgpool-pod> --previous
|
||||
# (empty)
|
||||
|
||||
# Gitea process had zero TCP connections to PostgreSQL port 5432
|
||||
# but many connections to Valkey port 6379
|
||||
cat /proc/<gitea-pid>/net/tcp | grep 1538 # 1538 = 5432 hex — no results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Immediate Fix Applied
|
||||
|
||||
```bash
|
||||
# Add the missing key (value = sr-check-password = changeme4 = base64: Y2hhbmdlbWU0)
|
||||
sudo k3s kubectl patch secret -n default gitea-postgresql-ha-postgresql \
|
||||
--type='json' \
|
||||
-p='[{"op":"add","path":"/data/pgpool-password","value":"Y2hhbmdlbWU0"}]'
|
||||
|
||||
# Restart pgpool
|
||||
sudo k3s kubectl delete pod -n default <pgpool-pod-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk: Fix Will Be Lost on helm upgrade
|
||||
|
||||
The patched secret is managed by Helm (annotation:
|
||||
`meta.helm.sh/release-name: gitea`). A `helm upgrade` will regenerate the
|
||||
secret from the chart template, which does not include `pgpool-password`,
|
||||
and the bug will recur.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — Add pgpool-password to Helm values
|
||||
|
||||
```task
|
||||
id: T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc"
|
||||
```
|
||||
|
||||
Create or update `helm/gitea-values.yaml` (or equivalent) to permanently
|
||||
include the pgpool-password so it survives `helm upgrade`:
|
||||
|
||||
```yaml
|
||||
postgresql-ha:
|
||||
postgresql:
|
||||
pgpoolPassword: <value matching sr-check-password>
|
||||
```
|
||||
|
||||
**Done when:** `helm upgrade gitea` completes and pgpool starts cleanly
|
||||
without manual secret patching.
|
||||
|
||||
---
|
||||
|
||||
### T02 — Add pgpool health check to smoke test
|
||||
|
||||
```task
|
||||
id: T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ab166073-30a7-4702-a037-4091e8706e20"
|
||||
```
|
||||
|
||||
Extend `tests/smoke_kube.sh` to assert:
|
||||
|
||||
```bash
|
||||
# All postgresql-ha pods Running
|
||||
kubectl get pods -n default | grep gitea-postgresql-ha | grep -v Running && exit 1
|
||||
|
||||
# pgpool specifically not in CrashLoopBackOff
|
||||
kubectl get pod -n default -l app.kubernetes.io/component=pgpool \
|
||||
-o jsonpath='{.items[0].status.containerStatuses[0].state}' | grep -v crash
|
||||
```
|
||||
|
||||
**Done when:** the smoke test catches a pgpool failure within 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Add HA failover test
|
||||
|
||||
```task
|
||||
id: T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "140da396-8e30-4f4d-b88c-c42c0cd46c01"
|
||||
```
|
||||
|
||||
Create `tests/test_ha_failover.sh` that:
|
||||
|
||||
1. Records Gitea login response time (baseline)
|
||||
2. Kills the primary PostgreSQL pod: `kubectl delete pod gitea-postgresql-ha-postgresql-0 -n default`
|
||||
3. Waits for repmgr to promote a replica (max 60s)
|
||||
4. Asserts Gitea login POST still succeeds within 10s
|
||||
5. Asserts pgpool pod is Running (not CrashLoopBackOff)
|
||||
6. Asserts all postgresql pods return to Running
|
||||
|
||||
This test must pass before any PostgreSQL HA deployment is considered done.
|
||||
|
||||
**Done when:** script exits 0 against a live cluster.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Document the incident in docs/
|
||||
|
||||
```task
|
||||
id: T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d"
|
||||
```
|
||||
|
||||
Add `docs/incidents/2026-03-10-pgpool-missing-secret.md` with the full
|
||||
timeline, root cause, and fix, so future operators understand what happened
|
||||
and how to recover.
|
||||
|
||||
**Done when:** doc committed and linked from `docs/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Bitnami postgresql-ha chart v16.2.2
|
||||
- Gitea Helm chart v12.2.0
|
||||
- Related decision: D3 (HA testing policy) in `DECISIONS.md`
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
---
|
||||
id: RAIL-BS-WP-0004
|
||||
type: workplan
|
||||
title: "Integrated Backup — S2 Kubernetes Runtime Layer"
|
||||
domain: financials
|
||||
repo: railiance-cluster
|
||||
status: finished
|
||||
owner: tegwick
|
||||
topic_slug: railiance
|
||||
state_hub_workstream_id: "7e8b0c20-51eb-40c9-9e3b-85dd380d7625"
|
||||
created: "2026-02-25"
|
||||
updated: "2026-03-26"
|
||||
---
|
||||
|
||||
> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
|
||||
# Integrated Backup — S2 Kubernetes Runtime Layer
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the Q3 (Operability & Resilience) integrated backup for
|
||||
railiance-cluster (S2). Backs up what S2 owns — the Kubernetes runtime state —
|
||||
encrypted with age, written to a local directory on the server. No external
|
||||
dependencies required.
|
||||
|
||||
## Architecture (Decision D4)
|
||||
|
||||
Each railiance repo implements its own backup for what it owns. No central
|
||||
backup service. See `DECISIONS.md` D4 for full rationale.
|
||||
|
||||
**Standard interface every railiance repo must provide:**
|
||||
|
||||
```bash
|
||||
make backup # encrypt + write to /opt/backup/railiance/<layer>/
|
||||
make restore # restore from most recent local backup
|
||||
```
|
||||
|
||||
Encryption: age, same key pair as SOPS secrets (`.sops.yaml` public key).
|
||||
Output: `/opt/backup/railiance/cluster/` on the server.
|
||||
|
||||
## What S2 (railiance-cluster) owns and must back up
|
||||
|
||||
| Asset | Why it matters |
|
||||
|---|---|
|
||||
| k3s etcd snapshots | Full cluster state — all workloads, configs, secrets |
|
||||
| Helm release values | Runtime values not in git (any manually applied overrides) |
|
||||
| kubeconfig | Admin access to the cluster |
|
||||
|
||||
**Not S2's responsibility:**
|
||||
- Custodian State Hub DB → the-custodian owns this
|
||||
- Operator workstation config (`.claude/`, `.gitconfig`) → operator's own concern
|
||||
- Application data (Gitea repos, uploads) → S5 (railiance-apps) owns this
|
||||
- PostgreSQL data volumes → S3 (railiance-platform) owns this
|
||||
|
||||
## Encryption
|
||||
|
||||
Reuse the age public key from `.sops.yaml`:
|
||||
|
||||
```bash
|
||||
AGE_PUBLIC_KEY=$(grep 'age:' .sops.yaml | awk '{print $2}')
|
||||
tar -czf - <assets> | age -r "${AGE_PUBLIC_KEY}" -o backup.tar.gz.age
|
||||
```
|
||||
|
||||
Decryption requires the private key at `~/.config/sops/age/keys.txt`
|
||||
(same key used for `sops -d`). No additional key management needed.
|
||||
|
||||
## Extension Point EP-RAIL-005
|
||||
|
||||
Once all five OAS layers implement this interface, the custodian can
|
||||
orchestrate a full-stack backup with:
|
||||
|
||||
```bash
|
||||
for repo in railiance-infra railiance-cluster railiance-platform \
|
||||
railiance-enablement railiance-apps; do
|
||||
make -C ~/$repo backup
|
||||
done
|
||||
```
|
||||
|
||||
No special protocol needed — just the standard interface.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — Define backup directory and encryption wrapper
|
||||
|
||||
```task
|
||||
id: T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4526a842-ea31-4874-9231-92ab556cfe7b"
|
||||
```
|
||||
|
||||
Create `tools/cmd/railiance-backup-s2` (replacing the old `railiance-backup`):
|
||||
|
||||
- Backup dir: `/opt/backup/railiance/cluster/` (create with `mkdir -p`)
|
||||
- Encrypt each artifact with age using public key from `.sops.yaml`
|
||||
- Write timestamp-named files: `etcd-<ts>.snap.age`, `helm-values-<ts>.tar.gz.age`, `kubeconfig-<ts>.yaml.age`
|
||||
- Keep last 7 of each type
|
||||
- Write `.last-backup` stamp
|
||||
- Exit 0 on success, non-zero on any failure
|
||||
- No network required
|
||||
|
||||
Also remove the old `tools/cmd/railiance-backup` (backed up Docker-based
|
||||
custodian DB — wrong scope, not applicable to this server).
|
||||
|
||||
**Done when:** `make backup` runs on COULOMBCORE without error and files
|
||||
appear in `/opt/backup/railiance/cluster/`.
|
||||
|
||||
---
|
||||
|
||||
### T02 — Back up k3s state (SQLite hot backup)
|
||||
|
||||
```task
|
||||
id: T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "a6313e06-1976-46a7-8e31-df4eb2eca880"
|
||||
```
|
||||
|
||||
k3s has built-in etcd snapshot support:
|
||||
|
||||
```bash
|
||||
sudo k3s etcd-snapshot save --name railiance-$(date -u +%Y%m%dT%H%M%SZ)
|
||||
# Default location: /var/lib/rancher/k3s/server/db/snapshots/
|
||||
```
|
||||
|
||||
Add to the backup script: take a fresh snapshot, encrypt with age,
|
||||
copy to `/opt/backup/railiance/cluster/`.
|
||||
|
||||
> **Note — verify etcd is in use before implementing:**
|
||||
> `k3s etcd-snapshot` only works if k3s was started with `--cluster-init`.
|
||||
> Without it, k3s uses SQLite and this command will fail.
|
||||
> Verify first: `sudo k3s etcd-snapshot ls 2>&1`
|
||||
|
||||
> **Note — sudo required:** etcd snapshot requires root. See T06 for how
|
||||
> this is resolved (backup runs under root's crontab).
|
||||
|
||||
**Done when:** backup includes a current etcd snapshot.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Back up Helm release values
|
||||
|
||||
```task
|
||||
id: T03
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3"
|
||||
```
|
||||
|
||||
Capture current runtime Helm values for all releases:
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm list -A -o json | \
|
||||
jq -r '.[].name + " " + .namespace' | \
|
||||
while read name ns; do
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm get values "$name" -n "$ns" -o yaml
|
||||
done
|
||||
```
|
||||
|
||||
Tar and age-encrypt into `helm-values-<ts>.tar.gz.age`.
|
||||
|
||||
> **Note — kubeconfig permissions:** `/etc/rancher/k3s/k3s.yaml` is root-readable
|
||||
> only by default. The backup script must either run as root (see T06) or k3s
|
||||
> must be configured with `--write-kubeconfig-mode=644`. Running as root
|
||||
> (via root crontab) is the chosen approach — no config change needed.
|
||||
|
||||
**Done when:** backup includes a snapshot of all Helm release values.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Back up kubeconfig
|
||||
|
||||
```task
|
||||
id: T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "08233868-d522-4117-bc4e-6c0f52545665"
|
||||
```
|
||||
|
||||
Age-encrypt `~/.kube/config-hosteurope` (or `/etc/rancher/k3s/k3s.yaml`)
|
||||
into `kubeconfig-<ts>.yaml.age` in the backup directory.
|
||||
|
||||
**Done when:** backup includes the encrypted kubeconfig.
|
||||
|
||||
---
|
||||
|
||||
### T05 — make restore target
|
||||
|
||||
```task
|
||||
id: T05
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8"
|
||||
```
|
||||
|
||||
Add `tools/cmd/railiance-restore-s2` that decrypts and lists available
|
||||
backups, with guided restore for the etcd snapshot case.
|
||||
|
||||
Restore of etcd from snapshot:
|
||||
```bash
|
||||
sudo k3s server --cluster-reset \
|
||||
--cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/<name>
|
||||
```
|
||||
|
||||
**Done when:** `make restore` prints available backups and a restore guide.
|
||||
|
||||
---
|
||||
|
||||
### T06 — Install cron job and run restore drill
|
||||
|
||||
```task
|
||||
id: T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "f8e4a094-c367-40eb-b895-da17bc144b07"
|
||||
```
|
||||
|
||||
#### Solving the sudo problem
|
||||
|
||||
The backup script needs root for two reasons:
|
||||
- `k3s etcd-snapshot save` requires root
|
||||
- `/etc/rancher/k3s/k3s.yaml` (kubeconfig) is root-readable only
|
||||
|
||||
**Solution: run the cron under root's crontab.**
|
||||
|
||||
This is the correct pattern for system-level backup jobs. It avoids a
|
||||
proliferating sudoers whitelist (one entry per command, brittle to maintain)
|
||||
and matches how tools like `rsnapshot`, `bacula`, and `borgbackup` work in
|
||||
production. The backup writes to `/opt/backup/` which is root-owned anyway.
|
||||
|
||||
Install the cron as root:
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
# Add:
|
||||
0 2 * * * make -C /home/tegwick/railiance-cluster backup >> /opt/backup/railiance/cluster/backup.log 2>&1
|
||||
```
|
||||
|
||||
Note: use the absolute path to the repo — `~` does not expand reliably in
|
||||
root's crontab unless HOME is set.
|
||||
|
||||
Verify it is installed:
|
||||
```bash
|
||||
sudo crontab -l | grep railiance
|
||||
```
|
||||
|
||||
#### Restore drill
|
||||
|
||||
Once T01–T04 are done, run a decrypt-and-verify drill:
|
||||
|
||||
```bash
|
||||
# Decrypt the etcd snapshot and verify it is a valid snapshot file
|
||||
sudo age -d -i ~/.config/sops/age/keys.txt \
|
||||
/opt/backup/railiance/cluster/etcd-$(ls /opt/backup/railiance/cluster/etcd-*.snap.age | sort -r | head -1 | xargs basename | sed 's/etcd-//;s/.snap.age//').snap.age \
|
||||
| file -
|
||||
|
||||
# Record the drill
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restore drill OK" \
|
||||
>> /opt/backup/railiance/cluster/restore-drill.log
|
||||
```
|
||||
|
||||
**Done when:** cron installed under root, drill completes without error,
|
||||
log entry written.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Decision D4: Integrated backup per capability (`DECISIONS.md`)
|
||||
- Decision D2: Nextcloud as optional offsite extension (still valid, not a requirement)
|
||||
- OAS Q3: Operability & Resilience
|
||||
- Extension point EP-RAIL-005: Custodian full-stack backup orchestration
|
||||
- k3s etcd snapshots: https://docs.k3s.io/datastore/backup-restore
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""Repo Manager — HelixForge repository integration boundary."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from repo_manager.cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"""Resolve exactly one authoritative owner for a State Hub record type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
AUTHORITY_SCHEMA = "repo-manager.hub-record-authority.v1"
|
||||
VALID_CLASSES = frozenset(
|
||||
{"file-derived", "hub-native-append", "hub-native-control", "derived-cache", "retired"}
|
||||
)
|
||||
DEFAULT_CONTRACT = Path(__file__).resolve().parents[2] / "config" / "hub-record-authority.yaml"
|
||||
|
||||
|
||||
class AuthorityError(ValueError):
|
||||
"""The requested route is missing or conflicts with the authority contract."""
|
||||
|
||||
|
||||
def load_authority_contract(path: Path = DEFAULT_CONTRACT) -> dict[str, Any]:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict) or data.get("schema") != AUTHORITY_SCHEMA:
|
||||
raise AuthorityError(f"unsupported authority contract: {path}")
|
||||
records = data.get("records")
|
||||
if not isinstance(records, dict) or not records:
|
||||
raise AuthorityError("authority contract has no records")
|
||||
for record_type, rule in records.items():
|
||||
if not isinstance(rule, dict):
|
||||
raise AuthorityError(f"invalid rule for {record_type}")
|
||||
if rule.get("class") not in VALID_CLASSES or not rule.get("owner"):
|
||||
raise AuthorityError(f"incomplete authority rule for {record_type}")
|
||||
return data
|
||||
|
||||
|
||||
def resolve_record_authority(
|
||||
record_type: str,
|
||||
*,
|
||||
repo_slug: str | None = None,
|
||||
domain_slug: str | None = None,
|
||||
claimed_owner: str | None = None,
|
||||
contract_path: Path = DEFAULT_CONTRACT,
|
||||
) -> dict[str, str]:
|
||||
"""Return the one authority route or reject an ambiguous/conflicting write."""
|
||||
contract = load_authority_contract(contract_path)
|
||||
rule = contract["records"].get(record_type)
|
||||
if not isinstance(rule, dict):
|
||||
raise AuthorityError(f"unknown record type: {record_type}")
|
||||
|
||||
record_class = str(rule["class"])
|
||||
owner = str(rule["owner"])
|
||||
if claimed_owner is not None and claimed_owner != owner:
|
||||
raise AuthorityError(
|
||||
f"authority mismatch for {record_type}: contract={owner}, claimed={claimed_owner}"
|
||||
)
|
||||
|
||||
if record_class == "file-derived":
|
||||
if not repo_slug or not domain_slug:
|
||||
raise AuthorityError(
|
||||
f"file-derived {record_type} requires repo_slug and domain_slug"
|
||||
)
|
||||
authority_key = f"repository:{domain_slug}/{repo_slug}"
|
||||
elif record_class == "retired":
|
||||
authority_key = f"archive:{record_type}"
|
||||
else:
|
||||
authority_key = f"hub:{owner}"
|
||||
|
||||
return {
|
||||
"schema": AUTHORITY_SCHEMA,
|
||||
"record_type": record_type,
|
||||
"record_class": record_class,
|
||||
"owner": owner,
|
||||
"authority_key": authority_key,
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
"""Rebuildable local-cache status and legacy provenance export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.request import urlopen
|
||||
|
||||
from repo_manager.gitops import head_sha
|
||||
from repo_manager.index_store import default_index_path, load_index
|
||||
from repo_manager.parse.record import iter_record_files
|
||||
from repo_manager.parse.register import iter_register_files
|
||||
from repo_manager.parse.workplan import iter_workplan_files
|
||||
from repo_manager.time import utc_now, utc_now_text
|
||||
|
||||
CACHE_STATUS_SCHEMA = "repo-manager.cache-status.v1"
|
||||
CLOSED_EXPORT_SCHEMA = "repo-manager.closed-workplan-provenance.v1"
|
||||
|
||||
|
||||
def authoritative_source_files(repo_root: Path) -> list[Path]:
|
||||
repo_root = repo_root.resolve()
|
||||
paths = {
|
||||
*iter_workplan_files(repo_root),
|
||||
*iter_record_files(repo_root),
|
||||
*iter_register_files(repo_root),
|
||||
}
|
||||
for name in (".repo-classification.yaml", "INTENT.md", "GOAL.md"):
|
||||
path = repo_root / name
|
||||
if path.is_file():
|
||||
paths.add(path)
|
||||
return sorted(paths, key=lambda path: str(path.relative_to(repo_root)))
|
||||
|
||||
|
||||
def source_fingerprint(repo_root: Path) -> tuple[str, list[str]]:
|
||||
"""Hash the paths and bytes that feed the local repository projection."""
|
||||
repo_root = repo_root.resolve()
|
||||
digest = hashlib.sha256()
|
||||
relative_paths: list[str] = []
|
||||
for path in authoritative_source_files(repo_root):
|
||||
relative = str(path.relative_to(repo_root))
|
||||
relative_paths.append(relative)
|
||||
digest.update(relative.encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
digest.update(path.read_bytes())
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest(), relative_paths
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("cache observed_at must be timezone-aware")
|
||||
return parsed
|
||||
|
||||
|
||||
def cache_status(repo_root: Path) -> dict[str, Any]:
|
||||
repo_root = repo_root.resolve()
|
||||
path = default_index_path(repo_root)
|
||||
checked_at = utc_now()
|
||||
if not path.is_file():
|
||||
return {
|
||||
"schema": CACHE_STATUS_SCHEMA,
|
||||
"ok": True,
|
||||
"advisory": True,
|
||||
"cache_present": False,
|
||||
"stale": True,
|
||||
"stale_reasons": ["cache_missing"],
|
||||
"checked_at": checked_at.isoformat().replace("+00:00", "Z"),
|
||||
"repo_root": str(repo_root),
|
||||
"index_path": str(path),
|
||||
}
|
||||
|
||||
index = load_index(path)
|
||||
fingerprint, source_files = source_fingerprint(repo_root)
|
||||
stale_reasons: list[str] = []
|
||||
if not index.source_fingerprint:
|
||||
stale_reasons.append("legacy_index_missing_source_fingerprint")
|
||||
elif index.source_fingerprint != fingerprint:
|
||||
stale_reasons.append("authoritative_source_changed")
|
||||
try:
|
||||
observed_at = _parse_timestamp(index.observed_at)
|
||||
age_seconds = max(0, int((checked_at - observed_at).total_seconds()))
|
||||
except (TypeError, ValueError):
|
||||
observed_at = None
|
||||
age_seconds = None
|
||||
stale_reasons.append("invalid_observed_at")
|
||||
|
||||
return {
|
||||
"schema": CACHE_STATUS_SCHEMA,
|
||||
"ok": True,
|
||||
"advisory": True,
|
||||
"cache_present": True,
|
||||
"stale": bool(stale_reasons),
|
||||
"stale_reasons": stale_reasons,
|
||||
"checked_at": checked_at.isoformat().replace("+00:00", "Z"),
|
||||
"observed_at": index.observed_at,
|
||||
"age_seconds": age_seconds,
|
||||
"repo_root": str(repo_root),
|
||||
"index_path": str(path),
|
||||
"indexed_head_sha": index.head_sha,
|
||||
"current_head_sha": head_sha(repo_root),
|
||||
"source_fingerprint": index.source_fingerprint,
|
||||
"current_source_fingerprint": fingerprint,
|
||||
"source_file_count": len(source_files),
|
||||
"record_count": len(index.work_records),
|
||||
}
|
||||
|
||||
|
||||
def build_closed_provenance_export(
|
||||
workplans: list[dict[str, Any]],
|
||||
repos: list[dict[str, Any]],
|
||||
*,
|
||||
source: str,
|
||||
) -> dict[str, Any]:
|
||||
repo_slugs = {str(repo["id"]): repo.get("slug") for repo in repos if repo.get("id")}
|
||||
rows = [
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"repo_id": row.get("repo_id"),
|
||||
"repo_slug": repo_slugs.get(str(row.get("repo_id"))),
|
||||
"slug": row.get("slug"),
|
||||
"title": row.get("title"),
|
||||
"status": row.get("status"),
|
||||
"owner": row.get("owner"),
|
||||
"created_at": row.get("created_at"),
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
for row in workplans
|
||||
if row.get("status") in {"finished", "archived"} and not row.get("backing_filename")
|
||||
]
|
||||
rows.sort(key=lambda row: (str(row["status"]), str(row["repo_slug"]), str(row["slug"])))
|
||||
canonical_rows = json.dumps(rows, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return {
|
||||
"schema": CLOSED_EXPORT_SCHEMA,
|
||||
"ok": True,
|
||||
"generated_at": utc_now_text(),
|
||||
"source": source,
|
||||
"filter": "backing_filename is null and status in [finished, archived]",
|
||||
"count": len(rows),
|
||||
"rows_sha256": hashlib.sha256(canonical_rows).hexdigest(),
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def fetch_closed_provenance(api_base: str) -> dict[str, Any]:
|
||||
base = api_base.rstrip("/")
|
||||
with urlopen(f"{base}/workplans/", timeout=30) as response:
|
||||
workplans = json.load(response)
|
||||
with urlopen(f"{base}/repos/?limit=500", timeout=30) as response:
|
||||
repos = json.load(response)
|
||||
if not isinstance(workplans, list) or not isinstance(repos, list):
|
||||
raise TypeError("State Hub returned a non-list workplan or repository payload")
|
||||
return build_closed_provenance_export(workplans, repos, source=base)
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"""Versioned implementation of the Custodian repository-classification contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
CONTRACT_VERSION = "1.0"
|
||||
CATEGORIES = frozenset(
|
||||
{"experimental", "research", "project", "tooling", "product", "business", "publication"}
|
||||
)
|
||||
DOMAINS = frozenset(
|
||||
{
|
||||
"infotech",
|
||||
"financials",
|
||||
"communication",
|
||||
"consumer",
|
||||
"health",
|
||||
"industrials",
|
||||
"energy",
|
||||
"utilities",
|
||||
"materials",
|
||||
"realestate",
|
||||
"crypto",
|
||||
"agents",
|
||||
"space",
|
||||
"government",
|
||||
}
|
||||
)
|
||||
BUSINESS_STAKE = frozenset(
|
||||
{
|
||||
"execution",
|
||||
"intelligence",
|
||||
"finance",
|
||||
"legal",
|
||||
"sales",
|
||||
"experience",
|
||||
"technology",
|
||||
"operations",
|
||||
"product",
|
||||
"people",
|
||||
"procurement",
|
||||
"sustainability",
|
||||
"automation",
|
||||
}
|
||||
)
|
||||
BUSINESS_MECHANICS = frozenset({"intention", "control", "coordination", "operation", "adaptation"})
|
||||
_TAG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClassificationIssue:
|
||||
field: str
|
||||
message: str
|
||||
|
||||
|
||||
class ClassificationError(ValueError):
|
||||
def __init__(self, issues: list[ClassificationIssue]):
|
||||
self.issues = issues
|
||||
super().__init__("; ".join(f"{issue.field}: {issue.message}" for issue in issues))
|
||||
|
||||
|
||||
def _list(data: dict[str, Any], field: str, issues: list[ClassificationIssue]) -> list[str]:
|
||||
value = data.get(field, [])
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
||||
issues.append(ClassificationIssue(field, "must be a list of strings"))
|
||||
return []
|
||||
if len(value) != len(set(value)):
|
||||
issues.append(ClassificationIssue(field, "must not contain duplicates"))
|
||||
return value
|
||||
|
||||
|
||||
def validate_classification(data: dict[str, Any]) -> list[ClassificationIssue]:
|
||||
"""Validate required fields and controlled vocabularies from canon v1.0."""
|
||||
issues: list[ClassificationIssue] = []
|
||||
category = data.get("category")
|
||||
domain = data.get("domain")
|
||||
if category not in CATEGORIES:
|
||||
issues.append(ClassificationIssue("category", f"must be one of {', '.join(sorted(CATEGORIES))}"))
|
||||
if domain not in DOMAINS:
|
||||
issues.append(ClassificationIssue("domain", f"must be one of {', '.join(sorted(DOMAINS))}"))
|
||||
|
||||
secondary = _list(data, "secondary_domains", issues)
|
||||
unknown_domains = sorted(set(secondary) - DOMAINS)
|
||||
if unknown_domains:
|
||||
issues.append(ClassificationIssue("secondary_domains", f"unknown values: {', '.join(unknown_domains)}"))
|
||||
if domain in secondary:
|
||||
issues.append(ClassificationIssue("secondary_domains", "must not repeat the primary domain"))
|
||||
|
||||
tags = _list(data, "capability_tags", issues)
|
||||
invalid_tags = sorted(tag for tag in tags if not _TAG_RE.fullmatch(tag))
|
||||
if invalid_tags:
|
||||
issues.append(ClassificationIssue("capability_tags", f"not lowercase kebab-case: {', '.join(invalid_tags)}"))
|
||||
|
||||
stake = _list(data, "business_stake", issues)
|
||||
unknown_stake = sorted(set(stake) - BUSINESS_STAKE)
|
||||
if unknown_stake:
|
||||
issues.append(ClassificationIssue("business_stake", f"unknown values: {', '.join(unknown_stake)}"))
|
||||
|
||||
mechanics = _list(data, "business_mechanics", issues)
|
||||
unknown_mechanics = sorted(set(mechanics) - BUSINESS_MECHANICS)
|
||||
if unknown_mechanics:
|
||||
issues.append(
|
||||
ClassificationIssue("business_mechanics", f"unknown values: {', '.join(unknown_mechanics)}")
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def require_valid_classification(data: dict[str, Any]) -> dict[str, Any]:
|
||||
issues = validate_classification(data)
|
||||
if issues:
|
||||
raise ClassificationError(issues)
|
||||
return data
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,3 @@
|
|||
from repo_manager.commands.task_status import update_task_status
|
||||
|
||||
__all__ = ["update_task_status"]
|
||||
|
|
@ -0,0 +1,946 @@
|
|||
"""Governed scaffolding for rapp-* managed workload packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PACKAGE_TYPES = (
|
||||
"helm-managed-platform-service",
|
||||
"manifest-managed-platform-service",
|
||||
"knative-managed-service",
|
||||
"grouped-composition",
|
||||
)
|
||||
RAILS = ("rail-kubernetes", "rail-knative")
|
||||
CLASSIFICATIONS = ("public", "internal", "confidential", "restricted")
|
||||
CRITICALITIES = ("low", "medium", "high", "critical")
|
||||
COMPUTE_REEFS = ("reef-railiance",)
|
||||
SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
REEF_SLUG = re.compile(r"^reef-[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
EXPOSE_RE = re.compile(r"^EXPOSE\s+(\d+)", re.MULTILINE)
|
||||
USER_RE = re.compile(r"^USER\s+(\d+)", re.MULTILINE)
|
||||
HEALTH_PATH_RE = re.compile(r'path:\s*["\']?(/[A-Za-z0-9._/-]+)')
|
||||
IMAGE_PIN_RE = re.compile(
|
||||
r"(forgejo\.coulomb\.social/coulomb/[a-z0-9-]+@)sha256:[0-9a-f]{64}"
|
||||
)
|
||||
UNSET_DIGEST = "sha256:" + ("0" * 64)
|
||||
|
||||
_FAMILY_VALIDATOR = Path.home() / "railiance-master" / "tools" / "validate-family-declarations.py"
|
||||
|
||||
|
||||
def _refuse(message: str) -> dict[str, Any]:
|
||||
return {"ok": False, "error": message}
|
||||
|
||||
|
||||
def _write(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content if content.endswith("\n") else content + "\n")
|
||||
|
||||
|
||||
def _underscore(app: str) -> str:
|
||||
return app.replace("-", "_")
|
||||
|
||||
|
||||
def init(
|
||||
path: Path,
|
||||
*,
|
||||
app: str,
|
||||
ownership_repo: str,
|
||||
rail: str = "rail-kubernetes",
|
||||
classification: str = "confidential",
|
||||
criticality: str = "high",
|
||||
package_type: str = "manifest-managed-platform-service",
|
||||
purpose: str | None = None,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
check = _check_identity(app, ownership_repo, rail, package_type, classification, criticality)
|
||||
if check:
|
||||
return check
|
||||
|
||||
rapp_id = f"rapp-{app}"
|
||||
dest = path.expanduser().resolve()
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
declaration = dest / "declarations" / "rapp.yaml"
|
||||
if declaration.is_file() and not force:
|
||||
return _refuse(f"{declaration} already exists; refuse to overwrite a live contract")
|
||||
|
||||
purpose_text = purpose or f"Package and operate the {app} service on Railiance."
|
||||
files = {
|
||||
".repo-classification.yaml": (
|
||||
"repo_classification:\n"
|
||||
' standard: Repo Classification Standard\n'
|
||||
' version: "1.0"\n'
|
||||
" classified_by: repo-manager\n"
|
||||
" category: project\n"
|
||||
" domain: infotech\n"
|
||||
),
|
||||
"README.md": f"# {rapp_id}\n\nManaged runtime package for `{app}`.\n",
|
||||
"INTENT.md": (
|
||||
f"# Intent\n\nProvide the managed Railiance runtime package for `{app}`.\n"
|
||||
),
|
||||
"SCOPE.md": (
|
||||
"# Scope\n\n## In scope\n\n- packaging, smoke, rollback, and the family declaration\n\n"
|
||||
"## Out of scope\n\n- application domain ownership\n- credential values\n"
|
||||
),
|
||||
"AGENTS.md": (
|
||||
f"# Repository agent guide\n\nWorkplans use the prefix "
|
||||
f"`{rapp_id.upper()}-WP-`.\n\n"
|
||||
"Cross-repo waits: attach to a `prj-` driver "
|
||||
"(`coordination-engine` `spec/cross-owner-wait-mode-v0.1.md`). "
|
||||
"Do not strand a `wait` or implement residual flavor.\n"
|
||||
),
|
||||
"CLAUDE.md": f"# {rapp_id}\n\n@SCOPE.md\n@AGENTS.md\n",
|
||||
"Makefile": (
|
||||
"SHELL := /bin/bash\n\n.PHONY: check\n\ncheck:\n"
|
||||
"\tpython3 -m unittest discover -s tests -v\n"
|
||||
),
|
||||
".gitignore": ".rendered/\n__pycache__/\n",
|
||||
"declarations/rapp.yaml": _declaration(
|
||||
rapp_id=rapp_id,
|
||||
app=app,
|
||||
ownership_repo=ownership_repo,
|
||||
rail=rail,
|
||||
classification=classification,
|
||||
criticality=criticality,
|
||||
package_type=package_type,
|
||||
purpose=purpose_text,
|
||||
),
|
||||
f"workplans/{rapp_id.upper()}-WP-0001-bootstrap.md": (
|
||||
f"---\nid: {rapp_id.upper()}-WP-0001\ntype: workplan\n"
|
||||
f'title: "Bootstrap {rapp_id}"\nstatus: proposed\n'
|
||||
f"flavor: planning\ndepends_on: []\n---\n\n"
|
||||
f"# Bootstrap {rapp_id}\n"
|
||||
),
|
||||
}
|
||||
written = []
|
||||
for rel, content in files.items():
|
||||
target = dest / rel
|
||||
if target.is_file() and not force and rel not in {"declarations/rapp.yaml"}:
|
||||
continue
|
||||
_write(target, content)
|
||||
written.append(rel)
|
||||
(dest / "workplans" / "archived").mkdir(exist_ok=True)
|
||||
return {"ok": True, "path": str(dest), "rapp_id": rapp_id, "written": written}
|
||||
|
||||
|
||||
def _check_identity(
|
||||
app: str,
|
||||
ownership_repo: str,
|
||||
rail: str,
|
||||
package_type: str,
|
||||
classification: str,
|
||||
criticality: str,
|
||||
) -> dict[str, Any] | None:
|
||||
if not SLUG.match(app) or app.startswith("rapp-"):
|
||||
return _refuse("app must be a workload slug without the rapp- prefix")
|
||||
if rail not in RAILS:
|
||||
return _refuse(f"unknown rail {rail!r}; allowed: {', '.join(RAILS)}")
|
||||
if package_type not in PACKAGE_TYPES:
|
||||
return _refuse(f"unknown package_type {package_type!r}")
|
||||
if classification not in CLASSIFICATIONS:
|
||||
return _refuse(f"unknown classification {classification!r}")
|
||||
if criticality not in CRITICALITIES:
|
||||
return _refuse(f"unknown criticality {criticality!r}")
|
||||
if not SLUG.match(ownership_repo) or ownership_repo.startswith("rapp-"):
|
||||
return _refuse("ownership_repo must be an app or layer slug, not the rapp itself")
|
||||
return None
|
||||
|
||||
|
||||
def _declaration(
|
||||
*,
|
||||
rapp_id: str,
|
||||
app: str,
|
||||
ownership_repo: str,
|
||||
rail: str,
|
||||
classification: str,
|
||||
criticality: str,
|
||||
package_type: str,
|
||||
purpose: str,
|
||||
) -> str:
|
||||
return (
|
||||
"kind: managed-workload-package\n"
|
||||
"repo_family: rapp\n"
|
||||
f"rapp_id: {rapp_id}\n"
|
||||
f"repo: {rapp_id}\n"
|
||||
f"ownership_repo: {ownership_repo}\n"
|
||||
"contract_version: 1.0.0\n"
|
||||
"readiness_state: draft\n"
|
||||
"workload_identity:\n"
|
||||
f" name: {app}\n"
|
||||
f" package_type: {package_type}\n"
|
||||
f"data_classification: {classification}\n"
|
||||
f"criticality: {criticality}\n"
|
||||
f"primary_rail: {rail}\n"
|
||||
"supported_rails:\n"
|
||||
f" - {rail}\n"
|
||||
"bound_reefs: []\n"
|
||||
"runtime_dependencies:\n"
|
||||
" - kubernetes-api\n"
|
||||
" - openbao-database-secrets-engine\n"
|
||||
"composition:\n"
|
||||
f" purpose: {purpose}\n"
|
||||
" member_repos:\n"
|
||||
f" - repo: {rapp_id}\n"
|
||||
" role: managed runtime package\n"
|
||||
" deployables:\n"
|
||||
f" - {app}\n"
|
||||
"rollout_contract:\n"
|
||||
" default_mode: kubectl-server-side-apply\n"
|
||||
"smoke_contract:\n"
|
||||
" required:\n"
|
||||
" - healthz-ok\n"
|
||||
"rollback_contract:\n"
|
||||
" order:\n"
|
||||
" - previous-immutable-image-digest\n"
|
||||
" - apply-reviewed-git-revision\n"
|
||||
)
|
||||
|
||||
|
||||
def inspect_app(from_app: Path, app: str) -> dict[str, Any]:
|
||||
root = from_app.expanduser().resolve()
|
||||
container = ""
|
||||
for name in ("Containerfile", "Dockerfile"):
|
||||
candidate = root / name
|
||||
if candidate.is_file():
|
||||
container = candidate.read_text()
|
||||
break
|
||||
expose = EXPOSE_RE.search(container)
|
||||
user = USER_RE.search(container)
|
||||
port = int(expose.group(1)) if expose else 8080
|
||||
uid = int(user.group(1)) if user else 10001
|
||||
deploy_dir = root / "deploy"
|
||||
deploy_files = sorted(deploy_dir.glob("*.yaml")) if deploy_dir.is_dir() else []
|
||||
health = "/healthz"
|
||||
ready = "/readyz"
|
||||
blob = container
|
||||
for path in deploy_files:
|
||||
blob += "\n" + path.read_text()
|
||||
for src in (root / "src").rglob("*.py") if (root / "src").is_dir() else []:
|
||||
try:
|
||||
blob += "\n" + src.read_text()
|
||||
except OSError:
|
||||
continue
|
||||
if len(blob) > 400_000:
|
||||
break
|
||||
paths = HEALTH_PATH_RE.findall(blob)
|
||||
if "/health" in paths and "/healthz" not in paths:
|
||||
health = "/health"
|
||||
ready = "/health"
|
||||
if "/healthz" in paths:
|
||||
health = "/healthz"
|
||||
if "/readyz" in paths:
|
||||
ready = "/readyz"
|
||||
digest = UNSET_DIGEST
|
||||
pin = IMAGE_PIN_RE.search(blob)
|
||||
if pin:
|
||||
digest = "sha256:" + pin.group(0).rsplit("sha256:", 1)[1]
|
||||
return {
|
||||
"app": app,
|
||||
"root": str(root),
|
||||
"port": port,
|
||||
"uid": uid,
|
||||
"health_path": health,
|
||||
"ready_path": ready,
|
||||
"image_repository": f"forgejo.coulomb.social/coulomb/{app}",
|
||||
"image_digest": digest,
|
||||
"deploy_files": [str(p) for p in deploy_files],
|
||||
"has_chart": (root / "helm").is_dir() or (root / "charts").is_dir(),
|
||||
"has_image_workflow": (root / ".forgejo" / "workflows" / "image.yaml").is_file(),
|
||||
}
|
||||
|
||||
|
||||
def skeleton(
|
||||
path: Path,
|
||||
*,
|
||||
from_app: Path,
|
||||
app: str | None = None,
|
||||
package_type: str = "manifest-managed-platform-service",
|
||||
force: bool = False,
|
||||
dedicated_postgres: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
dest = path.expanduser().resolve()
|
||||
app_name = app or dest.name.removeprefix("rapp-")
|
||||
if package_type == "helm-managed-platform-service":
|
||||
return _refuse("Helm skeleton is not generated; pass an existing chart or use manifests")
|
||||
if package_type not in PACKAGE_TYPES:
|
||||
return _refuse(f"unknown package_type {package_type!r}")
|
||||
facts = inspect_app(from_app, app_name)
|
||||
written: list[str] = []
|
||||
notes: list[str] = []
|
||||
if facts["deploy_files"]:
|
||||
manifests = dest / "manifests"
|
||||
if manifests.exists() and any(manifests.glob("*.yaml")) and not force:
|
||||
return _refuse(f"{manifests} already has manifests; pass --force to replace")
|
||||
manifests.mkdir(parents=True, exist_ok=True)
|
||||
for src in facts["deploy_files"]:
|
||||
target = manifests / Path(src).name
|
||||
shutil.copy2(src, target)
|
||||
written.append(str(target.relative_to(dest)))
|
||||
notes.append(f"absorbed {len(facts['deploy_files'])} file(s) from {from_app}/deploy")
|
||||
else:
|
||||
runtime = dest / "manifests" / "runtime.yaml"
|
||||
if runtime.is_file() and not force:
|
||||
return _refuse(f"{runtime} already exists; pass --force to replace")
|
||||
_write(runtime, _runtime_manifest(app_name, facts))
|
||||
written.append("manifests/runtime.yaml")
|
||||
notes.append("generated Deployment/Service/ServiceAccount/NetworkPolicy")
|
||||
|
||||
if dedicated_postgres:
|
||||
notes.append("dedicated Cluster not generated; pass reviewed CNPG YAML by hand")
|
||||
|
||||
makefile = dest / "Makefile"
|
||||
if not makefile.is_file() or force or makefile.read_text().count("\n") < 12:
|
||||
_write(makefile, _makefile(app_name, facts, dest.name))
|
||||
written.append("Makefile")
|
||||
render = dest / "tools" / "render.py"
|
||||
if not render.is_file() or force:
|
||||
_write(render, _render_py(app_name))
|
||||
render.chmod(0o755)
|
||||
written.append("tools/render.py")
|
||||
verify = dest / "tools" / "verify_live.sh"
|
||||
if not verify.is_file() or force:
|
||||
_write(verify, _verify_sh(app_name, facts))
|
||||
verify.chmod(0o755)
|
||||
written.append("tools/verify_live.sh")
|
||||
test = dest / "tests" / "test_packaging.py"
|
||||
if not test.is_file() or force:
|
||||
_write(test, _packaging_test(app_name))
|
||||
written.append("tests/test_packaging.py")
|
||||
return {"ok": True, "path": str(dest), "facts": facts, "written": written, "notes": notes}
|
||||
|
||||
|
||||
def ensure_image_workflow(from_app: Path, app: str) -> dict[str, Any]:
|
||||
root = from_app.expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
return _refuse(f"app checkout missing: {root}")
|
||||
target = root / ".forgejo" / "workflows" / "image.yaml"
|
||||
if target.is_file():
|
||||
return {"ok": True, "path": str(target), "written": False, "note": "already present"}
|
||||
_write(target, _image_workflow(app))
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(target),
|
||||
"written": True,
|
||||
"note": "first deploy waits on the CI digest; no workstation build",
|
||||
}
|
||||
|
||||
|
||||
def draft_postgres_consumer(
|
||||
path: Path,
|
||||
*,
|
||||
app: str,
|
||||
family_root: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
dest = path.expanduser().resolve()
|
||||
app_name = app or dest.name.removeprefix("rapp-")
|
||||
slug = _underscore(app_name)
|
||||
draft = (
|
||||
"apiVersion: rapp-postgres.railiance.io/v1alpha1\n"
|
||||
"kind: PostgresConsumer\n"
|
||||
"metadata:\n"
|
||||
f" name: {app_name}\n"
|
||||
"spec:\n"
|
||||
f" database: {slug}\n"
|
||||
f" schema: {slug}\n"
|
||||
f" costAttributionKey: platform:{app_name}\n"
|
||||
f" clientNamespaces: [{app_name}]\n"
|
||||
" roles:\n"
|
||||
f" owner: {slug}_owner\n"
|
||||
f" migration: {slug}_migrate\n"
|
||||
f" runtime: {slug}_app\n"
|
||||
" tenantKeyingRequired: true\n"
|
||||
" # Draft only. Do not apply from this package. Review in rapp-postgres.\n"
|
||||
)
|
||||
handoff = dest / "handoffs" / "postgres-consumer.yaml"
|
||||
search = (family_root or dest.parent).resolve()
|
||||
live = search / "rapp-postgres" / "consumers" / f"{app_name}.yaml"
|
||||
notes = []
|
||||
if live.is_file():
|
||||
_write(
|
||||
dest / "handoffs" / "README.md",
|
||||
f"# Handoffs\n\nPostgres consumer already lives at `{live}`.\n"
|
||||
"This package does not apply it.\n",
|
||||
)
|
||||
notes.append(f"existing consumer left in place: {live}")
|
||||
return {"ok": True, "path": str(live), "written": False, "notes": notes}
|
||||
_write(handoff, draft)
|
||||
notes.append(str(handoff))
|
||||
if (search / "rapp-postgres" / "consumers").is_dir():
|
||||
target = search / "rapp-postgres" / "consumers" / f"{app_name}.yaml"
|
||||
if not target.is_file():
|
||||
_write(target, draft)
|
||||
notes.append(str(target))
|
||||
return {"ok": True, "path": str(handoff), "written": True, "notes": notes}
|
||||
|
||||
|
||||
def wrap(
|
||||
path: Path,
|
||||
*,
|
||||
app: str,
|
||||
ownership_repo: str,
|
||||
from_app: Path,
|
||||
rail: str = "rail-kubernetes",
|
||||
classification: str = "confidential",
|
||||
criticality: str = "high",
|
||||
package_type: str = "manifest-managed-platform-service",
|
||||
purpose: str | None = None,
|
||||
force: bool = False,
|
||||
dedicated_postgres: bool = False,
|
||||
family_root: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
dest = path.expanduser().resolve()
|
||||
created = init(
|
||||
dest,
|
||||
app=app,
|
||||
ownership_repo=ownership_repo,
|
||||
rail=rail,
|
||||
classification=classification,
|
||||
criticality=criticality,
|
||||
package_type=package_type,
|
||||
purpose=purpose,
|
||||
force=force,
|
||||
)
|
||||
if not created.get("ok"):
|
||||
return created
|
||||
skel = skeleton(
|
||||
dest,
|
||||
from_app=from_app,
|
||||
app=app,
|
||||
package_type=package_type,
|
||||
force=force,
|
||||
dedicated_postgres=dedicated_postgres,
|
||||
)
|
||||
if not skel.get("ok"):
|
||||
return skel
|
||||
image = ensure_image_workflow(from_app, app)
|
||||
if not image.get("ok"):
|
||||
return image
|
||||
consumer = {} if dedicated_postgres else draft_postgres_consumer(
|
||||
dest, app=app, family_root=family_root or dest.parent
|
||||
)
|
||||
if consumer and not consumer.get("ok"):
|
||||
return consumer
|
||||
checked = validate(dest, family_root=family_root or dest.parent)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(dest),
|
||||
"init": created,
|
||||
"skeleton": skel,
|
||||
"image_workflow": image,
|
||||
"postgres_consumer": consumer,
|
||||
"validate": checked,
|
||||
"placed": False,
|
||||
"applied": False,
|
||||
}
|
||||
|
||||
|
||||
def place(path: Path, *, reef: str, family_root: Path | None = None) -> dict[str, Any]:
|
||||
if not REEF_SLUG.match(reef):
|
||||
return _refuse(f"unknown reef {reef!r}")
|
||||
if reef == "reef-storage":
|
||||
return _refuse("reef-storage hosts no rail; it is a consumed capability")
|
||||
dest = path.expanduser().resolve()
|
||||
declaration = dest / "declarations" / "rapp.yaml"
|
||||
if not declaration.is_file():
|
||||
return _refuse(f"missing {declaration}")
|
||||
search = (family_root or dest.parent).resolve()
|
||||
if reef not in COMPUTE_REEFS and not (search / reef).is_dir():
|
||||
return _refuse(f"reef {reef!r} is not a known compute reef")
|
||||
text = declaration.read_text()
|
||||
if re.search(r"^bound_reefs:\n - ", text, re.MULTILINE):
|
||||
text = re.sub(
|
||||
r"^bound_reefs:\n(?: - .+\n)+",
|
||||
f"bound_reefs:\n - {reef}\n",
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
else:
|
||||
text = re.sub(
|
||||
r"^bound_reefs:\s*\[\]\s*$",
|
||||
f"bound_reefs:\n - {reef}",
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if "exposure:" in text and "posture: public" in text:
|
||||
return _refuse("place does not grant public exposure; edit exposure separately")
|
||||
declaration.write_text(text)
|
||||
return {"ok": True, "path": str(dest), "bound_reefs": [reef]}
|
||||
|
||||
|
||||
def validate(path: Path, *, family_root: Path | None = None) -> dict[str, Any]:
|
||||
dest = path.expanduser().resolve()
|
||||
declaration = dest / "declarations" / "rapp.yaml"
|
||||
if not declaration.is_file():
|
||||
return _refuse(f"missing {declaration}")
|
||||
missing = [
|
||||
name
|
||||
for name in (
|
||||
"README.md",
|
||||
"INTENT.md",
|
||||
"SCOPE.md",
|
||||
"AGENTS.md",
|
||||
".repo-classification.yaml",
|
||||
)
|
||||
if not (dest / name).is_file()
|
||||
]
|
||||
if missing:
|
||||
return _refuse(f"missing bootstrap files: {', '.join(missing)}")
|
||||
|
||||
if _FAMILY_VALIDATOR.is_file():
|
||||
import tempfile
|
||||
|
||||
search_root = (family_root or dest.parent).resolve()
|
||||
with tempfile.TemporaryDirectory(prefix="rmgr-rapp-") as tmp:
|
||||
tmp_root = Path(tmp)
|
||||
(tmp_root / dest.name).symlink_to(dest)
|
||||
for sibling in ("rail-kubernetes", "rail-knative", "reef-railiance"):
|
||||
candidate = search_root / sibling
|
||||
if candidate.is_dir():
|
||||
(tmp_root / sibling).symlink_to(candidate)
|
||||
proc = subprocess.run(
|
||||
["python3", str(_FAMILY_VALIDATOR), "--root", str(tmp_root)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return {
|
||||
"ok": proc.returncode == 0,
|
||||
"path": str(dest),
|
||||
"validator": str(_FAMILY_VALIDATOR),
|
||||
"stdout": proc.stdout.strip(),
|
||||
"stderr": proc.stderr.strip(),
|
||||
"exit_code": proc.returncode,
|
||||
}
|
||||
|
||||
text = declaration.read_text()
|
||||
required = (
|
||||
"kind: managed-workload-package",
|
||||
"repo_family: rapp",
|
||||
"ownership_repo:",
|
||||
"primary_rail:",
|
||||
"composition:",
|
||||
)
|
||||
absent = [item for item in required if item not in text]
|
||||
if absent:
|
||||
return _refuse(f"declaration missing {absent}")
|
||||
return {"ok": True, "path": str(dest), "validator": None}
|
||||
|
||||
|
||||
def pin_image(path: Path, digest: str) -> dict[str, Any]:
|
||||
if not DIGEST.fullmatch(digest):
|
||||
return _refuse("digest must be sha256:<64 lowercase hex>")
|
||||
dest = path.expanduser().resolve()
|
||||
rewritten = 0
|
||||
for rel in ("manifests", "bindings", "declarations"):
|
||||
root = dest / rel
|
||||
if not root.exists():
|
||||
continue
|
||||
files = [root] if root.is_file() else list(root.rglob("*"))
|
||||
for file in files:
|
||||
if not file.is_file():
|
||||
continue
|
||||
text = file.read_text()
|
||||
updated, n = IMAGE_PIN_RE.subn(rf"\g<1>{digest}", text)
|
||||
if n:
|
||||
file.write_text(updated)
|
||||
rewritten += n
|
||||
if rewritten == 0:
|
||||
return _refuse("no forgejo.coulomb.social digest pin found")
|
||||
makefile = dest / "Makefile"
|
||||
if makefile.is_file():
|
||||
makefile.write_text(
|
||||
re.sub(r"sha256:[0-9a-f]{64}", digest, makefile.read_text(), count=1)
|
||||
)
|
||||
return {"ok": True, "path": str(dest), "digest": digest, "rewritten": rewritten}
|
||||
|
||||
|
||||
def _runtime_manifest(app: str, facts: dict[str, Any]) -> str:
|
||||
port = facts["port"]
|
||||
uid = facts["uid"]
|
||||
health = facts["health_path"]
|
||||
ready = facts["ready_path"]
|
||||
image = f"{facts['image_repository']}@{facts['image_digest']}"
|
||||
return f"""apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {app}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {app}
|
||||
namespace: {app}
|
||||
labels:
|
||||
app.kubernetes.io/name: {app}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {app}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {app}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: {app}
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: {uid}
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: {app}
|
||||
image: {image}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {port}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
readOnlyRootFilesystem: true
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: {ready}
|
||||
port: http
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: {health}
|
||||
port: http
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {app}
|
||||
namespace: {app}
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: {app}
|
||||
ports:
|
||||
- name: http
|
||||
port: {port}
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {app}
|
||||
namespace: {app}
|
||||
automountServiceAccountToken: false
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {app}-default-deny
|
||||
namespace: {app}
|
||||
spec:
|
||||
podSelector: {{}}
|
||||
policyTypes: [Ingress, Egress]
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {app}-runtime
|
||||
namespace: {app}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {app}
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- {{protocol: TCP, port: {port}}}
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- {{protocol: UDP, port: 53}}
|
||||
- {{protocol: TCP, port: 53}}
|
||||
"""
|
||||
|
||||
|
||||
def _makefile(app: str, facts: dict[str, Any], field_manager: str) -> str:
|
||||
digest = facts["image_digest"]
|
||||
repo = facts["image_repository"]
|
||||
return f"""SHELL := /bin/bash
|
||||
|
||||
TARGET ?= railiance01
|
||||
NAMESPACE := {app}
|
||||
DEPLOYMENT := {app}
|
||||
IMAGE_REPOSITORY := {repo}
|
||||
IMAGE_DIGEST ?= {digest}
|
||||
DIGEST ?=
|
||||
RENDERED := .rendered
|
||||
MANIFESTS := $(wildcard manifests/*.yaml)
|
||||
|
||||
.PHONY: check test render validate-inputs server-dry-run deploy status verify-live rollback
|
||||
|
||||
check: test
|
||||
|
||||
test:
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
validate-inputs:
|
||||
@echo "$(IMAGE_DIGEST)" | grep -Eq '^sha256:[0-9a-f]{{64}}$$' \\
|
||||
|| (echo "IMAGE_DIGEST must be sha256:<64 hex>" >&2; exit 2)
|
||||
|
||||
render: validate-inputs
|
||||
@rm -rf $(RENDERED)
|
||||
@mkdir -p $(RENDERED)
|
||||
IMAGE_REPOSITORY=$(IMAGE_REPOSITORY) IMAGE_DIGEST=$(IMAGE_DIGEST) \\
|
||||
python3 tools/render.py $(MANIFESTS) --out $(RENDERED)
|
||||
|
||||
server-dry-run: render
|
||||
{{ for f in $(RENDERED)/*.yaml; do echo '---'; cat "$$f"; done; }} \\
|
||||
| ssh -o BatchMode=yes $(TARGET) \\
|
||||
kubectl apply --server-side --force-conflicts --dry-run=server -f -
|
||||
|
||||
deploy: render
|
||||
{{ for f in $(RENDERED)/*.yaml; do echo '---'; cat "$$f"; done; }} \\
|
||||
| ssh -o BatchMode=yes $(TARGET) \\
|
||||
kubectl apply --server-side --force-conflicts --field-manager={field_manager} -f -
|
||||
ssh -o BatchMode=yes $(TARGET) \\
|
||||
kubectl -n $(NAMESPACE) rollout status deployment/$(DEPLOYMENT) --timeout=180s
|
||||
|
||||
status:
|
||||
ssh -o BatchMode=yes $(TARGET) kubectl -n $(NAMESPACE) get deploy,pods,svc
|
||||
|
||||
verify-live: validate-inputs
|
||||
EXPECTED_IMAGE="$(IMAGE_REPOSITORY)@$(IMAGE_DIGEST)" \\
|
||||
TARGET=$(TARGET) NAMESPACE=$(NAMESPACE) ./tools/verify_live.sh
|
||||
|
||||
rollback:
|
||||
@test -n "$(DIGEST)" || (echo "DIGEST=sha256:<64 hex> is required" >&2; exit 2)
|
||||
$(MAKE) deploy IMAGE_DIGEST=$(DIGEST)
|
||||
"""
|
||||
|
||||
|
||||
def _render_py(app: str) -> str:
|
||||
return f'''#!/usr/bin/env python3
|
||||
"""Rewrite digest pins into rendered manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
IMAGE_LINE = re.compile(
|
||||
r"(image:\\s+)(forgejo\\.coulomb\\.social/coulomb/{app})(@sha256:[0-9a-f]{{64}})?"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifests", nargs="+", type=Path)
|
||||
parser.add_argument("--out", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
repo = os.environ.get("IMAGE_REPOSITORY", "forgejo.coulomb.social/coulomb/{app}")
|
||||
digest = os.environ.get("IMAGE_DIGEST", "")
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{{64}}", digest):
|
||||
print("IMAGE_DIGEST must be sha256:<64 lowercase hex>", file=sys.stderr)
|
||||
return 2
|
||||
replacement = rf"\\1{{repo}}@{{digest}}"
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
for src in args.manifests:
|
||||
(args.out / src.name).write_text(IMAGE_LINE.sub(replacement, src.read_text()))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
'''
|
||||
|
||||
|
||||
def _verify_sh(app: str, facts: dict[str, Any]) -> str:
|
||||
port = facts["port"]
|
||||
health = facts["health_path"]
|
||||
ready = facts["ready_path"]
|
||||
return f"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TARGET="${{TARGET:-railiance01}}"
|
||||
NAMESPACE="${{NAMESPACE:-{app}}}"
|
||||
DEPLOYMENT="${{DEPLOYMENT:-{app}}}"
|
||||
EXPECTED_IMAGE="${{EXPECTED_IMAGE:?EXPECTED_IMAGE is required}}"
|
||||
|
||||
remote() {{
|
||||
ssh -o BatchMode=yes "$TARGET" "$1"
|
||||
}}
|
||||
|
||||
live_image="$(remote "kubectl -n ${{NAMESPACE}} get deploy ${{DEPLOYMENT}} -o jsonpath='{{.spec.template.spec.containers[0].image}}'")"
|
||||
if [[ "$live_image" != "$EXPECTED_IMAGE" ]]; then
|
||||
echo "digest mismatch live=$live_image expected=$EXPECTED_IMAGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
remote "kubectl -n ${{NAMESPACE}} rollout status deployment/${{DEPLOYMENT}} --timeout=60s >/dev/null"
|
||||
health="$(remote "kubectl -n ${{NAMESPACE}} exec deploy/${{DEPLOYMENT}} -- python3 -c \\"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:{port}{health}').status)\\"")"
|
||||
ready="$(remote "kubectl -n ${{NAMESPACE}} exec deploy/${{DEPLOYMENT}} -- python3 -c \\"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:{port}{ready}').status)\\"")"
|
||||
[[ "$health" == "200" ]]
|
||||
[[ "$ready" == "200" ]]
|
||||
python3 - "$live_image" "$health" "$ready" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({{
|
||||
"health_ok": sys.argv[2] == "200",
|
||||
"ready_ok": sys.argv[3] == "200",
|
||||
"live_image": sys.argv[1],
|
||||
"secret_values_observed": False,
|
||||
}}, sort_keys=True))
|
||||
PY
|
||||
"""
|
||||
|
||||
|
||||
def _packaging_test(app: str) -> str:
|
||||
return f'''import pathlib
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class PackagingTests(unittest.TestCase):
|
||||
def test_declaration_names_the_workload(self):
|
||||
decl = (ROOT / "declarations" / "rapp.yaml").read_text()
|
||||
self.assertIn("ownership_repo:", decl)
|
||||
self.assertIn("name: {app}", decl.split("workload_identity:", 1)[1][:200])
|
||||
self.assertNotIn("posture: public", decl)
|
||||
|
||||
def test_package_has_no_floating_tag(self):
|
||||
texts = []
|
||||
for folder in ("manifests", "declarations"):
|
||||
root = ROOT / folder
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*"):
|
||||
if path.is_file():
|
||||
texts.append(path.read_text())
|
||||
blob = "\\n".join(texts)
|
||||
self.assertNotRegex(blob, re.compile(r"image:\\s+[^\\n]+:(latest|main)\\b"))
|
||||
self.assertNotIn("sk-", blob)
|
||||
'''
|
||||
|
||||
|
||||
def _image_workflow(app: str) -> str:
|
||||
return f"""name: Build and Publish Container Image
|
||||
|
||||
# Images are built by CI from a tarball of the pushed commit, never from
|
||||
# a workstation working tree.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".forgejo/workflows/image.yaml"
|
||||
- "Containerfile"
|
||||
- "src/**"
|
||||
- "pyproject.toml"
|
||||
- "README.md"
|
||||
- "LICENSE"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: forgejo.coulomb.social
|
||||
IMAGE_NAME: coulomb/{app}
|
||||
DOCKER_HOST: tcp://127.0.0.1:2375
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: container-build
|
||||
steps:
|
||||
- name: Build and push image
|
||||
env:
|
||||
REGISTRY_USER: ${{{{ secrets.REGISTRY_USER }}}}
|
||||
REGISTRY_TOKEN: ${{{{ secrets.REGISTRY_TOKEN }}}}
|
||||
run: |
|
||||
set -eu
|
||||
REF="${{GITHUB_SHA:-main}}"
|
||||
SHORT="${{REF:0:7}}"
|
||||
mkdir -p buildctx "${{HOME}}/bin"
|
||||
wget -qO /tmp/repo.tar.gz \\
|
||||
"https://forgejo.coulomb.social/${{GITHUB_REPOSITORY}}/archive/${{SHORT}}.tar.gz"
|
||||
tar xzf /tmp/repo.tar.gz -C buildctx --strip-components=1
|
||||
wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \\
|
||||
| tar xz --strip-components=1 -C "${{HOME}}/bin" docker/docker
|
||||
export PATH="${{HOME}}/bin:${{PATH}}"
|
||||
echo "${{REGISTRY_TOKEN}}" | docker login "${{REGISTRY}}" -u "${{REGISTRY_USER}}" --password-stdin
|
||||
IMAGE="${{REGISTRY}}/${{IMAGE_NAME}}"
|
||||
docker build -f buildctx/Containerfile -t "${{IMAGE}}:latest" -t "${{IMAGE}}:main-${{SHORT}}" buildctx
|
||||
docker push "${{IMAGE}}:latest"
|
||||
docker push "${{IMAGE}}:main-${{SHORT}}"
|
||||
echo "pushed ${{IMAGE}}:latest and ${{IMAGE}}:main-${{SHORT}}"
|
||||
|
||||
- name: Report immutable digest
|
||||
run: |
|
||||
set -eu
|
||||
export PATH="${{HOME}}/bin:${{PATH}}"
|
||||
IMAGE="${{REGISTRY}}/${{IMAGE_NAME}}"
|
||||
SHORT="${{GITHUB_SHA:0:7}}"
|
||||
docker inspect --format='{{{{index .RepoDigests 0}}}}' "${{IMAGE}}:main-${{SHORT}}"
|
||||
"""
|
||||
|
||||
|
||||
def add_rapp_parser(sub: argparse._SubParsersAction) -> None:
|
||||
rapp = sub.add_parser("rapp", help="Scaffold or validate a rapp-* package")
|
||||
rapp_sub = rapp.add_subparsers(dest="rapp_command", required=True)
|
||||
|
||||
p_init = rapp_sub.add_parser("init", help="Create a draft rapp-* baseline")
|
||||
p_init.add_argument("--path", required=True, help="Destination checkout")
|
||||
p_init.add_argument("--app", required=True, help="Workload name (no rapp- prefix)")
|
||||
p_init.add_argument("--ownership-repo", required=True)
|
||||
p_init.add_argument("--rail", default="rail-kubernetes", choices=RAILS)
|
||||
p_init.add_argument("--classification", default="confidential", choices=CLASSIFICATIONS)
|
||||
p_init.add_argument("--criticality", default="high", choices=CRITICALITIES)
|
||||
p_init.add_argument("--package-type", default="manifest-managed-platform-service", choices=PACKAGE_TYPES)
|
||||
p_init.add_argument("--purpose", default=None)
|
||||
p_init.add_argument("--force", action="store_true")
|
||||
|
||||
p_skel = rapp_sub.add_parser("skeleton", help="Generate or absorb runtime manifests")
|
||||
p_skel.add_argument("--path", required=True)
|
||||
p_skel.add_argument("--from-app", required=True)
|
||||
p_skel.add_argument("--app", default=None)
|
||||
p_skel.add_argument("--package-type", default="manifest-managed-platform-service", choices=PACKAGE_TYPES)
|
||||
p_skel.add_argument("--force", action="store_true")
|
||||
p_skel.add_argument("--dedicated-postgres", action="store_true")
|
||||
|
||||
p_wrap = rapp_sub.add_parser("wrap", help="init + skeleton + image + consumer + validate")
|
||||
p_wrap.add_argument("--path", required=True)
|
||||
p_wrap.add_argument("--app", required=True)
|
||||
p_wrap.add_argument("--ownership-repo", required=True)
|
||||
p_wrap.add_argument("--from-app", required=True)
|
||||
p_wrap.add_argument("--rail", default="rail-kubernetes", choices=RAILS)
|
||||
p_wrap.add_argument("--classification", default="confidential", choices=CLASSIFICATIONS)
|
||||
p_wrap.add_argument("--criticality", default="high", choices=CRITICALITIES)
|
||||
p_wrap.add_argument("--package-type", default="manifest-managed-platform-service", choices=PACKAGE_TYPES)
|
||||
p_wrap.add_argument("--purpose", default=None)
|
||||
p_wrap.add_argument("--family-root", default=None)
|
||||
p_wrap.add_argument("--force", action="store_true")
|
||||
p_wrap.add_argument("--dedicated-postgres", action="store_true")
|
||||
|
||||
p_place = rapp_sub.add_parser("place", help="Set bound_reefs only")
|
||||
p_place.add_argument("--path", required=True)
|
||||
p_place.add_argument("--reef", default="reef-railiance")
|
||||
p_place.add_argument("--family-root", default=None)
|
||||
|
||||
p_val = rapp_sub.add_parser("validate", help="Validate a rapp-* checkout")
|
||||
p_val.add_argument("--path", required=True)
|
||||
p_val.add_argument("--family-root", default=None)
|
||||
|
||||
p_pin = rapp_sub.add_parser("pin-image", help="Rewrite digest pins")
|
||||
p_pin.add_argument("--path", required=True)
|
||||
p_pin.add_argument("--digest", required=True)
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
"""Governed mutation commands for repository-owned intake and decision records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager import dual_run, idempotency
|
||||
from repo_manager.commands.workplan import CommandResult
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||
from repo_manager.time import utc_now_text
|
||||
|
||||
_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_PROTECTED = frozenset(
|
||||
{
|
||||
"id",
|
||||
"kind",
|
||||
"title",
|
||||
"status",
|
||||
"notes",
|
||||
"created",
|
||||
"updated",
|
||||
"routed_at",
|
||||
"closed_at",
|
||||
"decided_at",
|
||||
}
|
||||
)
|
||||
_OPERATIONS = {
|
||||
"intake": frozenset({"create", "route", "note", "close"}),
|
||||
"decision": frozenset({"create", "update", "resolve"}),
|
||||
}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return utc_now_text()
|
||||
|
||||
|
||||
def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected", **evidence},
|
||||
error={"code": code, "message": message, "correlation_id": correlation_id},
|
||||
)
|
||||
|
||||
|
||||
def _command(kind: str, operation: str) -> str:
|
||||
verbs = {
|
||||
("intake", "create"): "repo.work.create_intake",
|
||||
("intake", "route"): "repo.work.route_intake",
|
||||
("intake", "note"): "repo.work.add_intake_note",
|
||||
("intake", "close"): "repo.work.close_intake",
|
||||
("decision", "create"): "repo.work.create_decision",
|
||||
("decision", "update"): "repo.work.update_decision",
|
||||
("decision", "resolve"): "repo.work.resolve_decision",
|
||||
}
|
||||
return verbs.get((kind, operation), f"repo.work.{operation}_{kind}")
|
||||
|
||||
|
||||
def _default_path(repo_root: Path, kind: str) -> Path:
|
||||
return repo_root / f"{kind}s" / f"{kind}s.md"
|
||||
|
||||
|
||||
def _find(repo_root: Path, kind: str, record_id: str) -> list[tuple[Path, Any]]:
|
||||
found: list[tuple[Path, Any]] = []
|
||||
for path in iter_record_files(repo_root):
|
||||
for record in parse_record_file(path, repo_root=repo_root):
|
||||
if record.kind == kind and (record.id == record_id or record.uuid == record_id):
|
||||
found.append((path, record))
|
||||
return found
|
||||
|
||||
|
||||
def _dump_block(record: dict[str, Any]) -> str:
|
||||
return "```yaml\n" + yaml.safe_dump(record, sort_keys=False, allow_unicode=True).rstrip() + "\n```"
|
||||
|
||||
|
||||
def _write_new(path: Path, kind: str, record: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
previous = path.read_text(encoding="utf-8") if path.is_file() else f"# {kind.title()} records\n"
|
||||
text = previous.rstrip() + f"\n\n## {record['id']} — {record['title']}\n\n" + _dump_block(record) + "\n"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def _replace(path: Path, record: Any, data: dict[str, Any]) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated = text[: record.block_start] + _dump_block(data) + text[record.block_end :]
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def mutate_record(
|
||||
repo_root: Path,
|
||||
kind: str,
|
||||
record_id: str,
|
||||
*,
|
||||
operation: str,
|
||||
title: str | None = None,
|
||||
status: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
route_to: str | None = None,
|
||||
note: str | None = None,
|
||||
author: str | None = None,
|
||||
outcome: str | None = None,
|
||||
rationale: str | None = None,
|
||||
decided_by: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr command",
|
||||
commit: bool = True,
|
||||
push: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Mutate one intake or decision YAML block in its authoritative Markdown file."""
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
command = _command(kind, operation)
|
||||
repo_root = repo_root.resolve()
|
||||
payload = {
|
||||
"repo_root": str(repo_root),
|
||||
"kind": kind,
|
||||
"record_id": record_id,
|
||||
"operation": operation,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"data": data,
|
||||
"route_to": route_to,
|
||||
"note": note,
|
||||
"author": author,
|
||||
"outcome": outcome,
|
||||
"rationale": rationale,
|
||||
"decided_by": decided_by,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
}
|
||||
digest = idempotency.payload_hash(payload)
|
||||
if idempotency_key:
|
||||
prior = idempotency.get(idempotency_key)
|
||||
if prior:
|
||||
if prior.get("payload_hash") != digest:
|
||||
return _reject(command, correlation_id, "conflict", "idempotency key reused")
|
||||
previous = prior.get("result") or {}
|
||||
return CommandResult(
|
||||
command=previous.get("command", command),
|
||||
status=previous.get("status", "applied"),
|
||||
correlation_id=previous.get("correlation_id", correlation_id),
|
||||
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
|
||||
error=previous.get("error"),
|
||||
)
|
||||
|
||||
if kind not in _OPERATIONS or operation not in _OPERATIONS[kind]:
|
||||
return _reject(command, correlation_id, "validation_error", f"invalid {kind!r} operation {operation!r}")
|
||||
if not _ID_RE.fullmatch(record_id):
|
||||
return _reject(command, correlation_id, "validation_error", f"invalid record id {record_id!r}")
|
||||
if data is not None and not isinstance(data, dict):
|
||||
return _reject(command, correlation_id, "validation_error", "data must be an object")
|
||||
protected = _PROTECTED.intersection(data or {})
|
||||
if protected:
|
||||
return _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
f"data contains protected fields: {', '.join(sorted(protected))}",
|
||||
)
|
||||
|
||||
current_head = head_sha(repo_root)
|
||||
if expected_head_sha and current_head and current_head != expected_head_sha:
|
||||
result = _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"precondition_failed",
|
||||
f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||
head_sha=current_head,
|
||||
)
|
||||
if result.error:
|
||||
result.error["retryable"] = True
|
||||
return result
|
||||
|
||||
matches = _find(repo_root, kind, record_id)
|
||||
if len(matches) > 1:
|
||||
return _reject(command, correlation_id, "conflict", f"record {record_id!r} is duplicated")
|
||||
match = matches[0] if matches else None
|
||||
now = _now()
|
||||
|
||||
if operation == "create":
|
||||
if match:
|
||||
return _reject(command, correlation_id, "conflict", f"record {record_id!r} exists")
|
||||
if not title or not title.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "create requires title")
|
||||
record_data: dict[str, Any] = {
|
||||
"id": record_id,
|
||||
"kind": kind,
|
||||
"title": title.strip(),
|
||||
"status": status or "open",
|
||||
**(data or {}),
|
||||
"created": now,
|
||||
"updated": now,
|
||||
}
|
||||
path = _default_path(repo_root, kind)
|
||||
_write_new(path, kind, record_data)
|
||||
else:
|
||||
if not match:
|
||||
return _reject(command, correlation_id, "not_found", f"record {record_id!r} not found")
|
||||
path, parsed = match
|
||||
record_data = dict(parsed.raw)
|
||||
if kind == "intake" and operation == "route":
|
||||
if not route_to or not route_to.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "route requires route_to")
|
||||
record_data.update(status="routed", routed_to=route_to.strip(), routed_at=now)
|
||||
elif kind == "intake" and operation == "note":
|
||||
if not note or not note.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "note requires content")
|
||||
record_data.setdefault("notes", []).append(
|
||||
{"content": note.strip(), "author": author or "codex", "created": now}
|
||||
)
|
||||
elif kind == "intake" and operation == "close":
|
||||
record_data.update(status="closed", closed_at=now)
|
||||
if outcome:
|
||||
record_data["outcome"] = outcome.strip()
|
||||
elif kind == "decision" and operation == "update":
|
||||
if title is None and status is None and not data:
|
||||
return _reject(command, correlation_id, "validation_error", "update requires a field")
|
||||
if title is not None:
|
||||
if not title.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "title cannot be empty")
|
||||
record_data["title"] = title.strip()
|
||||
if status is not None:
|
||||
if not status.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "status cannot be empty")
|
||||
record_data["status"] = status.strip()
|
||||
record_data.update(data or {})
|
||||
else:
|
||||
if not rationale or not rationale.strip() or not decided_by or not decided_by.strip():
|
||||
return _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
"resolve requires rationale and decided_by",
|
||||
)
|
||||
record_data.update(
|
||||
status="resolved",
|
||||
rationale=rationale.strip(),
|
||||
decided_by=decided_by.strip(),
|
||||
decided_at=now,
|
||||
)
|
||||
record_data["updated"] = now
|
||||
_replace(path, parsed, record_data)
|
||||
|
||||
relative_path = str(path.relative_to(repo_root))
|
||||
git_sha: str | None = None
|
||||
push_ok: bool | None = None
|
||||
push_message: str | None = None
|
||||
if commit:
|
||||
try:
|
||||
git_sha = commit_paths(
|
||||
repo_root,
|
||||
[relative_path],
|
||||
message=(
|
||||
f"{command} {record_id}\n\n"
|
||||
f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n"
|
||||
),
|
||||
)
|
||||
except GitError as exc:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "failed", "files_touched": [relative_path], "git_sha": None},
|
||||
error={"code": "internal", "message": str(exc), "correlation_id": correlation_id},
|
||||
)
|
||||
if push:
|
||||
push_ok, push_message = push_ff(repo_root)
|
||||
|
||||
snapshot, index = observe_repository(repo_root, slug=repo_slug)
|
||||
append_event(
|
||||
index,
|
||||
{
|
||||
"type": "repo.command.applied",
|
||||
"command": command,
|
||||
"operation": operation,
|
||||
"correlation_id": correlation_id,
|
||||
"kind": kind,
|
||||
"id": record_id,
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [relative_path],
|
||||
"source": "repo-manager",
|
||||
},
|
||||
)
|
||||
save_index(index)
|
||||
dual_run.record_mutation(
|
||||
source="repo-manager",
|
||||
kind=f"{kind}_{operation}",
|
||||
repo_slug=repo_slug or snapshot.get("slug"),
|
||||
detail={"id": record_id, "git_sha": git_sha, "correlation_id": correlation_id},
|
||||
)
|
||||
evidence: dict[str, Any] = {
|
||||
"status": "applied",
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [relative_path],
|
||||
"index_path": str(default_index_path(repo_root)),
|
||||
"source": "repo-manager",
|
||||
}
|
||||
if push:
|
||||
evidence.update(push_ok=push_ok, push_message=push_message)
|
||||
result = CommandResult(command=command, status="applied", correlation_id=correlation_id, evidence=evidence)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, digest, result.to_dict())
|
||||
return result
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
"""Governed mutation spine for repository-owned registers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager import dual_run, idempotency
|
||||
from repo_manager.commands.workplan import CommandResult
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.register import REGISTER_SCHEMA, SUPPORTED_REGISTER_KINDS, register_path
|
||||
from repo_manager.time import utc_now_text
|
||||
|
||||
_ENTRY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_PROTECTED_FIELDS = frozenset({"id", "title", "status", "notes", "created", "updated"})
|
||||
_REQUIRED_DATA = {
|
||||
"sbom-inventory": frozenset({"package_name", "ecosystem"}),
|
||||
"repo-goals": frozenset({"description"}),
|
||||
"upstream-contributions": frozenset({"type"}),
|
||||
"technical-debt": frozenset(),
|
||||
"extension-points": frozenset(),
|
||||
"register-entries": frozenset({"register_kind"}),
|
||||
}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return utc_now_text()
|
||||
|
||||
|
||||
def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected", **evidence},
|
||||
error={"code": code, "message": message, "correlation_id": correlation_id},
|
||||
)
|
||||
|
||||
|
||||
def _load(path: Path, kind: str) -> dict[str, Any]:
|
||||
if not path.is_file():
|
||||
return {"schema": REGISTER_SCHEMA, "kind": kind, "entries": []}
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise ValueError(f"invalid register YAML: {exc}") from exc
|
||||
if not isinstance(data, dict) or not isinstance(data.get("entries", []), list):
|
||||
raise TypeError("register must be a mapping with an entries list")
|
||||
if data.get("kind", kind) != kind:
|
||||
raise ValueError(f"register kind does not match filename {kind!r}")
|
||||
data.setdefault("schema", REGISTER_SCHEMA)
|
||||
data.setdefault("kind", kind)
|
||||
data.setdefault("entries", [])
|
||||
return data
|
||||
|
||||
|
||||
def _save(path: Path, document: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
yaml.safe_dump(document, sort_keys=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def mutate_register_entry(
|
||||
repo_root: Path,
|
||||
kind: str,
|
||||
entry_id: str,
|
||||
*,
|
||||
operation: str,
|
||||
title: str | None = None,
|
||||
status: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
note: str | None = None,
|
||||
note_author: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr command",
|
||||
commit: bool = True,
|
||||
push: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Upsert, defer, or annotate an entry in one of the shared register kinds."""
|
||||
command_map = {
|
||||
"upsert": "repo.register.upsert_entry",
|
||||
"defer": "repo.register.defer_entry",
|
||||
"note": "repo.register.add_note",
|
||||
}
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
command = command_map.get(operation, "repo.register.upsert_entry")
|
||||
repo_root = repo_root.resolve()
|
||||
payload = {
|
||||
"repo_root": str(repo_root),
|
||||
"kind": kind,
|
||||
"entry_id": entry_id,
|
||||
"operation": operation,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"data": data,
|
||||
"note": note,
|
||||
"note_author": note_author,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
}
|
||||
digest = idempotency.payload_hash(payload)
|
||||
if idempotency_key:
|
||||
prior = idempotency.get(idempotency_key)
|
||||
if prior:
|
||||
if prior.get("payload_hash") != digest:
|
||||
return _reject(command, correlation_id, "conflict", "idempotency key reused")
|
||||
previous = prior.get("result") or {}
|
||||
return CommandResult(
|
||||
command=previous.get("command", command),
|
||||
status=previous.get("status", "applied"),
|
||||
correlation_id=previous.get("correlation_id", correlation_id),
|
||||
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
|
||||
error=previous.get("error"),
|
||||
)
|
||||
|
||||
if operation not in command_map:
|
||||
return _reject(command, correlation_id, "validation_error", f"invalid operation {operation!r}")
|
||||
if kind not in SUPPORTED_REGISTER_KINDS:
|
||||
return _reject(command, correlation_id, "validation_error", f"unsupported register {kind!r}")
|
||||
if not _ENTRY_ID_RE.fullmatch(entry_id):
|
||||
return _reject(command, correlation_id, "validation_error", f"invalid entry id {entry_id!r}")
|
||||
if status is not None and not status.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "status cannot be empty")
|
||||
if data is not None and not isinstance(data, dict):
|
||||
return _reject(command, correlation_id, "validation_error", "data must be an object")
|
||||
protected = _PROTECTED_FIELDS.intersection(data or {})
|
||||
if protected:
|
||||
return _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
f"data contains protected fields: {', '.join(sorted(protected))}",
|
||||
)
|
||||
|
||||
current_head = head_sha(repo_root)
|
||||
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||
result = _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"precondition_failed",
|
||||
f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||
head_sha=current_head,
|
||||
)
|
||||
if result.error:
|
||||
result.error["retryable"] = True
|
||||
return result
|
||||
|
||||
snapshot, _index = observe_repository(repo_root, slug=repo_slug)
|
||||
slug = repo_slug or snapshot.get("slug")
|
||||
path = register_path(repo_root, kind)
|
||||
try:
|
||||
document = _load(path, kind)
|
||||
except (TypeError, ValueError) as exc:
|
||||
return _reject(command, correlation_id, "conflict", str(exc))
|
||||
entries = document["entries"]
|
||||
matches = [item for item in entries if isinstance(item, dict) and str(item.get("id")) == entry_id]
|
||||
if len(matches) > 1:
|
||||
return _reject(command, correlation_id, "conflict", f"duplicate entry id {entry_id!r}")
|
||||
entry = matches[0] if matches else None
|
||||
before = yaml.safe_dump(entry, sort_keys=True) if entry is not None else None
|
||||
now = _now()
|
||||
|
||||
if operation == "upsert":
|
||||
if entry is None:
|
||||
if not title or not title.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "new entry requires title")
|
||||
missing = sorted(key for key in _REQUIRED_DATA[kind] if key not in (data or {}))
|
||||
if missing:
|
||||
return _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
f"{kind} entry missing data fields: {', '.join(missing)}",
|
||||
)
|
||||
entry = {
|
||||
"id": entry_id,
|
||||
"title": title.strip(),
|
||||
"status": (status or "open").strip(),
|
||||
**(data or {}),
|
||||
"created": now,
|
||||
"updated": now,
|
||||
}
|
||||
entries.append(entry)
|
||||
else:
|
||||
if title is not None:
|
||||
if not title.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "title cannot be empty")
|
||||
entry["title"] = title.strip()
|
||||
if status is not None:
|
||||
entry["status"] = status.strip()
|
||||
entry.update(data or {})
|
||||
entry["updated"] = now
|
||||
elif operation == "defer":
|
||||
if entry is None:
|
||||
return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found")
|
||||
entry["status"] = (status or "deferred").strip()
|
||||
entry["updated"] = now
|
||||
else:
|
||||
if entry is None:
|
||||
return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found")
|
||||
if not note or not note.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "note requires content")
|
||||
entry.setdefault("notes", []).append(
|
||||
{"content": note.strip(), "author": note_author or "codex", "created": now}
|
||||
)
|
||||
entry["updated"] = now
|
||||
|
||||
after = yaml.safe_dump(entry, sort_keys=True)
|
||||
if before == after:
|
||||
result = CommandResult(
|
||||
command=command,
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "applied", "git_sha": current_head, "files_touched": [], "noop": True},
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, digest, result.to_dict())
|
||||
return result
|
||||
|
||||
_save(path, document)
|
||||
relative_path = str(path.relative_to(repo_root))
|
||||
git_sha: str | None = None
|
||||
push_ok: bool | None = None
|
||||
push_message: str | None = None
|
||||
if commit:
|
||||
try:
|
||||
git_sha = commit_paths(
|
||||
repo_root,
|
||||
[relative_path],
|
||||
message=(
|
||||
f"{command} {kind}/{entry_id}\n\n"
|
||||
f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n"
|
||||
),
|
||||
)
|
||||
except GitError as exc:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "failed", "files_touched": [relative_path], "git_sha": None},
|
||||
error={"code": "internal", "message": str(exc), "correlation_id": correlation_id},
|
||||
)
|
||||
if push:
|
||||
push_ok, push_message = push_ff(repo_root)
|
||||
|
||||
_snapshot_after, index_after = observe_repository(repo_root, slug=slug)
|
||||
append_event(
|
||||
index_after,
|
||||
{
|
||||
"type": "repo.command.applied",
|
||||
"command": command,
|
||||
"operation": operation,
|
||||
"correlation_id": correlation_id,
|
||||
"register_kind": kind,
|
||||
"entry_id": entry_id,
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [relative_path],
|
||||
"source": "repo-manager",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
index_after,
|
||||
{
|
||||
"type": "repo.work.indexed",
|
||||
"correlation_id": correlation_id,
|
||||
"kind": f"register:{kind}",
|
||||
"id": entry_id,
|
||||
"source_path": relative_path,
|
||||
},
|
||||
)
|
||||
save_index(index_after)
|
||||
dual_run.record_mutation(
|
||||
source="repo-manager",
|
||||
kind=f"register_{operation}",
|
||||
repo_slug=slug,
|
||||
detail={
|
||||
"register_kind": kind,
|
||||
"entry_id": entry_id,
|
||||
"git_sha": git_sha,
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
evidence: dict[str, Any] = {
|
||||
"status": "applied",
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [relative_path],
|
||||
"index_path": str(default_index_path(repo_root)),
|
||||
"source": "repo-manager",
|
||||
}
|
||||
if push:
|
||||
evidence["push_ok"] = push_ok
|
||||
evidence["push_message"] = push_message
|
||||
result = CommandResult(
|
||||
command=command,
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, digest, result.to_dict())
|
||||
return result
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
"""Onboard a repository into its authoritative State Hub (CUST-WP-0067-T04).
|
||||
|
||||
`hub-record-authority.yaml` assigns ``managed_repos`` to repo-manager as
|
||||
``file-derived``: the repository's committed ``.repo-classification.yaml`` is
|
||||
the source of truth and the hub holds a projection of it. Repo Manager owned
|
||||
that record type without exposing any command for it, so the only working path
|
||||
lived in the State Hub repo and defaulted to a local address — which is how
|
||||
seven weeks of onboarding landed in a local cache instead of central.
|
||||
|
||||
The order here is deliberate and follows ADR-010 decision 5: make the source
|
||||
file correct and *reachable* first, then project it. A hub record whose backing
|
||||
file is not pushed cannot be re-derived by anyone else.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from repo_manager.classification import validate_classification
|
||||
from repo_manager.gitops import head_sha, is_git_repo
|
||||
|
||||
CLASSIFICATION_FILENAME = ".repo-classification.yaml"
|
||||
|
||||
|
||||
class OnboardError(RuntimeError):
|
||||
"""Onboarding cannot proceed and the operator must decide what to do."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnboardResult:
|
||||
slug: str
|
||||
action: str = "unknown"
|
||||
steps: list[dict[str, Any]] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def step(self, name: str, ok: bool, **detail: Any) -> None:
|
||||
self.steps.append({"step": name, "ok": ok, **detail})
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "repo-manager.repo-onboard.v1",
|
||||
"slug": self.slug,
|
||||
"action": self.action,
|
||||
"steps": self.steps,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
import subprocess
|
||||
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(repo), *args], capture_output=True, text=True, check=False
|
||||
)
|
||||
return proc.stdout.strip() if proc.returncode == 0 else ""
|
||||
|
||||
|
||||
def _load_classification(repo: Path) -> dict[str, Any]:
|
||||
path = repo / CLASSIFICATION_FILENAME
|
||||
if not path.is_file():
|
||||
raise OnboardError(
|
||||
f"{CLASSIFICATION_FILENAME} is missing in {repo}. Classification is not "
|
||||
"mechanical — author it with the repository owner rather than guessing "
|
||||
"(see CUST-WP-0065-T01)."
|
||||
)
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
data = raw.get("repo_classification", raw)
|
||||
if not isinstance(data, dict):
|
||||
raise OnboardError(f"{CLASSIFICATION_FILENAME} does not contain a mapping")
|
||||
issues = validate_classification(data)
|
||||
if issues:
|
||||
detail = "; ".join(f"{i.field}: {i.message}" for i in issues)
|
||||
raise OnboardError(f"{CLASSIFICATION_FILENAME} is invalid: {detail}")
|
||||
return data
|
||||
|
||||
|
||||
def _source_is_reachable(repo: Path, result: OnboardResult) -> None:
|
||||
"""The backing file must be committed and pushed, or nothing can re-derive it."""
|
||||
dirty = _git(repo, "status", "--porcelain", "--", CLASSIFICATION_FILENAME)
|
||||
if dirty:
|
||||
raise OnboardError(
|
||||
f"{CLASSIFICATION_FILENAME} has uncommitted changes. Commit it first — "
|
||||
"the commit is the write (ADR-010 decision 4)."
|
||||
)
|
||||
|
||||
upstream = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||
if not upstream:
|
||||
raise OnboardError(
|
||||
"no upstream branch configured; central derives from what it can fetch"
|
||||
)
|
||||
ahead = _git(repo, "rev-list", "--count", "@{u}..HEAD")
|
||||
if ahead and ahead != "0":
|
||||
raise OnboardError(
|
||||
f"{ahead} commit(s) not pushed. Push before onboarding — a hub record "
|
||||
"whose source is only local cannot be re-derived."
|
||||
)
|
||||
result.step("source_reachable", True, upstream=upstream, head=head_sha(repo))
|
||||
|
||||
|
||||
def _hub(api_base: str, method: str, path: str, body: dict | None = None) -> tuple[int, Any]:
|
||||
url = api_base.rstrip("/") + path
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.request(method, url, json=body)
|
||||
except httpx.HTTPError as exc:
|
||||
raise OnboardError(f"cannot reach hub at {api_base}: {exc}") from exc
|
||||
if response.status_code == 204:
|
||||
return response.status_code, None
|
||||
try:
|
||||
return response.status_code, response.json()
|
||||
except ValueError:
|
||||
return response.status_code, {"_raw": response.text}
|
||||
|
||||
|
||||
def _classification_body(data: dict[str, Any]) -> dict[str, Any]:
|
||||
keys = (
|
||||
"category",
|
||||
"secondary_domains",
|
||||
"capability_tags",
|
||||
"business_stake",
|
||||
"business_mechanics",
|
||||
"classified_at",
|
||||
"classified_by",
|
||||
"standard_version",
|
||||
)
|
||||
return {k: data[k] for k in keys if data.get(k) is not None}
|
||||
|
||||
|
||||
def onboard_repo(
|
||||
repo_path: Path,
|
||||
*,
|
||||
api_base: str,
|
||||
dry_run: bool = False,
|
||||
slug: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
repo = Path(repo_path).expanduser().resolve()
|
||||
if not is_git_repo(repo):
|
||||
raise OnboardError(f"{repo} is not a git working copy")
|
||||
|
||||
slug = slug or repo.name
|
||||
result = OnboardResult(slug=slug)
|
||||
|
||||
data = _load_classification(repo)
|
||||
result.step("classification_valid", True, domain=data.get("domain"), category=data.get("category"))
|
||||
|
||||
_source_is_reachable(repo, result)
|
||||
|
||||
remote = _git(repo, "remote", "get-url", "origin") or None
|
||||
|
||||
code, existing = _hub(api_base, "GET", f"/repos/{slug}")
|
||||
exists = code == 200
|
||||
|
||||
if dry_run:
|
||||
result.action = "would-update" if exists else "would-create"
|
||||
result.step("hub_projection", True, dry_run=True, exists=exists, api_base=api_base)
|
||||
return result.to_dict()
|
||||
|
||||
if not exists:
|
||||
code, created = _hub(
|
||||
api_base,
|
||||
"POST",
|
||||
"/repos/",
|
||||
{
|
||||
"slug": slug,
|
||||
"name": slug,
|
||||
"domain_slug": data["domain"],
|
||||
"local_path": str(repo),
|
||||
"remote_url": remote,
|
||||
},
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise OnboardError(f"POST /repos/ failed ({code}): {created}")
|
||||
result.action = "created"
|
||||
result.step("hub_create", True, status=code)
|
||||
else:
|
||||
result.action = "updated"
|
||||
|
||||
# Correct a stale remote before anything else depends on it. The forge
|
||||
# migration moved every repository from Gitea to Forgejo but never updated
|
||||
# the hub, leaving 50 records pointing at a retired forge — and
|
||||
# `fix-consistency` matches a checkout to its record *by remote_url*, so it
|
||||
# could not find them and refused to register any of their work records.
|
||||
# The working copy's origin is the derivable truth (CUST-WP-0068-T06).
|
||||
if remote and existing and existing.get("remote_url") != remote:
|
||||
code, _ = _hub(api_base, "PATCH", f"/repos/{slug}", {"remote_url": remote})
|
||||
result.step(
|
||||
"remote_url_corrected", code in (200, 201),
|
||||
was=existing.get("remote_url"), now=remote, status=code,
|
||||
)
|
||||
|
||||
code, patched = _hub(api_base, "PATCH", f"/repos/{slug}", _classification_body(data))
|
||||
if code in (200, 201):
|
||||
result.step("hub_classification", True, status=code)
|
||||
else:
|
||||
# The authoritative record now exists, which is what stops the repository
|
||||
# from being recoverable only through a discardable cache. Classification
|
||||
# is a projection of a file that is committed and pushed, so it can be
|
||||
# re-derived at any time — do not fail the whole onboarding for it.
|
||||
result.step("hub_classification", False, status=code, detail=patched)
|
||||
result.warnings.append(
|
||||
f"classification not projected (PATCH returned {code}); the record exists "
|
||||
"and the file remains the source of truth, so re-run once the hub can "
|
||||
"validate classification"
|
||||
)
|
||||
|
||||
# Verify against the hub rather than trusting our own write.
|
||||
code, verified = _hub(api_base, "GET", f"/repos/{slug}")
|
||||
if code != 200:
|
||||
raise OnboardError(f"verification failed: GET /repos/{slug} returned {code}")
|
||||
if verified.get("category") is None and not any(
|
||||
st["step"] == "hub_classification" and st["ok"] for st in result.steps
|
||||
):
|
||||
result.action = f"{result.action}-unclassified"
|
||||
if verified.get("domain_slug") != data["domain"]:
|
||||
result.warnings.append(
|
||||
f"hub reports domain_slug={verified.get('domain_slug')!r}, file says {data['domain']!r}"
|
||||
)
|
||||
result.step("verified", True, domain_slug=verified.get("domain_slug"))
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
def render(report: dict[str, Any]) -> str:
|
||||
return json.dumps(report, indent=2)
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
"""Governed command: create flavor-correct repository files (RMGR-WP-0004-T03)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager.gitops import GitError, commit_paths, is_git_repo
|
||||
from repo_manager.hub_access import apply_hub_access_block, render_hub_access_block
|
||||
from repo_manager.standards import FLAVOR_MARKER_PREFIX, expected_workplan_prefix
|
||||
from repo_manager.time import utc_today
|
||||
|
||||
DURABLE_FLAVORS = ("experimental", "research", "tooling", "product", "business")
|
||||
FLAVORS = (*DURABLE_FLAVORS, "project")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
status: str
|
||||
evidence: dict[str, Any]
|
||||
error: dict[str, Any] | None = None
|
||||
correlation_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
out = {
|
||||
"command": "repo.scaffold",
|
||||
"status": self.status,
|
||||
"correlation_id": self.correlation_id,
|
||||
"evidence": self.evidence,
|
||||
}
|
||||
if self.error:
|
||||
out["error"] = self.error
|
||||
return out
|
||||
|
||||
|
||||
def _write(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not content.endswith("\n"):
|
||||
content += "\n"
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
def scaffold_repository(
|
||||
path: Path,
|
||||
*,
|
||||
flavor: str,
|
||||
slug: str | None = None,
|
||||
domain: str = "infotech",
|
||||
wp_prefix: str | None = None,
|
||||
force: bool = False,
|
||||
commit: bool = True,
|
||||
) -> CommandResult:
|
||||
cid = str(uuid.uuid4())
|
||||
dest = path.expanduser().resolve()
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
slug = slug or dest.name
|
||||
flavor = flavor.strip().lower()
|
||||
if flavor not in FLAVORS:
|
||||
return CommandResult(
|
||||
"rejected",
|
||||
{"path": str(dest)},
|
||||
{"message": f"unknown flavor {flavor!r}"},
|
||||
cid,
|
||||
)
|
||||
|
||||
prj = flavor == "project" or slug.startswith("prj-")
|
||||
if prj:
|
||||
flavor = "project"
|
||||
prefix = (wp_prefix or "").strip().upper()
|
||||
if prefix and not prefix.endswith("-WP"):
|
||||
prefix = prefix.rstrip("-") + "-WP"
|
||||
if not prefix:
|
||||
derived = expected_workplan_prefix(dest, slug=slug)
|
||||
return CommandResult(
|
||||
"rejected",
|
||||
{"path": str(dest), "suggested_prefix": derived},
|
||||
{
|
||||
"message": (
|
||||
"project flavor requires --wp-prefix derived from the "
|
||||
"project identity (never PRJ-WP-)"
|
||||
)
|
||||
},
|
||||
cid,
|
||||
)
|
||||
if prefix == FLAVOR_MARKER_PREFIX or prefix.startswith("PRJ-"):
|
||||
return CommandResult(
|
||||
"rejected",
|
||||
{"path": str(dest)},
|
||||
{"message": "refusing flavor-derived prefix PRJ-WP-"},
|
||||
cid,
|
||||
)
|
||||
else:
|
||||
prefix = (wp_prefix or expected_workplan_prefix(dest, slug=slug) or "REPO-WP").upper()
|
||||
if not prefix.endswith("-WP"):
|
||||
prefix = prefix.rstrip("-") + "-WP"
|
||||
|
||||
written: list[str] = []
|
||||
|
||||
def put(rel: str, content: str) -> None:
|
||||
target = dest / rel
|
||||
if target.exists() and not force:
|
||||
return
|
||||
_write(target, content)
|
||||
written.append(rel)
|
||||
|
||||
put(
|
||||
".repo-classification.yaml",
|
||||
"repo_classification:\n"
|
||||
f" category: {'project' if prj else flavor}\n"
|
||||
f" domain: {domain}\n"
|
||||
" secondary_domains: []\n"
|
||||
" capability_tags: []\n",
|
||||
)
|
||||
put("README.md", f"# {slug}\n\nSee {'GOAL.md' if prj else 'INTENT.md'} and SCOPE.md.\n")
|
||||
put(
|
||||
"SCOPE.md",
|
||||
f"# Scope\n\n## In scope\n\n- {slug}\n\n## Out of scope\n\n- TBD\n",
|
||||
)
|
||||
put(
|
||||
"AGENTS.md",
|
||||
f"# Agent instructions — {slug}\n\n"
|
||||
f"Orient: {'GOAL.md' if prj else 'INTENT.md'} → SCOPE.md → workplans/.\n"
|
||||
f"Workplan prefix: `{prefix}-`.\n\n"
|
||||
"## State Hub Integration\n\n"
|
||||
f"{render_hub_access_block()}\n\n"
|
||||
"## Cross-repo waits\n\n"
|
||||
"If blocked on another repo, look for a `prj-` driver workplan and "
|
||||
"follow `coordination-engine` `spec/cross-owner-wait-mode-v0.1.md`. "
|
||||
"Do not mint a stranded `wait`, implement `flavor: residual`, or "
|
||||
"message `ops-warden` for secrets.\n",
|
||||
)
|
||||
if prj:
|
||||
today = utc_today().isoformat()
|
||||
put(
|
||||
"GOAL.md",
|
||||
"---\n"
|
||||
f"repo: {slug}\n"
|
||||
"repo_flavor: project\n"
|
||||
"project_status: draft\n"
|
||||
f'started: "{today}"\n'
|
||||
"---\n\n"
|
||||
f"# Goal — {slug}\n\n"
|
||||
"## Outcome\n\nWhat success looks like.\n\n"
|
||||
"## Invariants\n\nRules that must hold during the project.\n\n"
|
||||
"## Success gates\n\nCheckable conditions for goal achieved.\n\n"
|
||||
"## Project retirement\n\n"
|
||||
"Archive this repository when gates are met and residuals have live owners.\n",
|
||||
)
|
||||
if (dest / "INTENT.md").exists() and not force:
|
||||
pass
|
||||
else:
|
||||
put(
|
||||
"INTENT.md",
|
||||
f"# INTENT\n\n> Why `{slug}` exists.\n\n## One-liner\n\nTBD.\n",
|
||||
)
|
||||
|
||||
(dest / "workplans" / "archived").mkdir(parents=True, exist_ok=True)
|
||||
if not prj:
|
||||
bootstrap = dest / "workplans" / f"{prefix}-0001-foundation.md"
|
||||
if force or not bootstrap.exists():
|
||||
_write(
|
||||
bootstrap,
|
||||
"---\n"
|
||||
f"id: {prefix}-0001\n"
|
||||
"type: workplan\n"
|
||||
f'title: "Foundation"\n'
|
||||
f"domain: {domain}\n"
|
||||
f"repo: {slug}\n"
|
||||
"status: proposed\n"
|
||||
"flavor: planning\n"
|
||||
"depends_on: []\n"
|
||||
"---\n\n"
|
||||
"# Foundation\n\n"
|
||||
"```task\n"
|
||||
f"id: {prefix}-0001-T01\n"
|
||||
"status: todo\n"
|
||||
"priority: high\n"
|
||||
"```\n\n"
|
||||
"Establish the repository baseline.\n",
|
||||
)
|
||||
written.append(str(bootstrap.relative_to(dest)))
|
||||
|
||||
evidence: dict[str, Any] = {
|
||||
"path": str(dest),
|
||||
"flavor": flavor,
|
||||
"slug": slug,
|
||||
"wp_prefix": prefix,
|
||||
"written": written,
|
||||
"prj_layout": prj,
|
||||
}
|
||||
if commit and is_git_repo(dest) and written:
|
||||
try:
|
||||
sha = commit_paths(dest, written, f"chore: scaffold {flavor} repository baseline")
|
||||
evidence["git_sha"] = sha
|
||||
except GitError as exc:
|
||||
return CommandResult("failed", evidence, {"message": str(exc)}, cid)
|
||||
|
||||
if not written:
|
||||
evidence["noop"] = True
|
||||
return CommandResult("applied", evidence, None, cid)
|
||||
|
||||
|
||||
def refresh_hub_access(path: Path, *, commit: bool = True) -> CommandResult:
|
||||
"""Replace the State Hub access table in AGENTS.md from the canonical map."""
|
||||
cid = str(uuid.uuid4())
|
||||
dest = path.expanduser().resolve()
|
||||
agents = dest / "AGENTS.md"
|
||||
evidence: dict[str, Any] = {"path": str(dest), "file": "AGENTS.md"}
|
||||
if not agents.is_file():
|
||||
return CommandResult(
|
||||
"rejected",
|
||||
evidence,
|
||||
{"message": "AGENTS.md is missing; scaffold the repository first"},
|
||||
cid,
|
||||
)
|
||||
original = agents.read_text(encoding="utf-8")
|
||||
updated, action = apply_hub_access_block(original)
|
||||
evidence["action"] = action
|
||||
if action == "noop":
|
||||
evidence["noop"] = True
|
||||
return CommandResult("applied", evidence, None, cid)
|
||||
agents.write_text(updated, encoding="utf-8")
|
||||
evidence["written"] = ["AGENTS.md"]
|
||||
if commit and is_git_repo(dest):
|
||||
try:
|
||||
evidence["git_sha"] = commit_paths(
|
||||
dest,
|
||||
["AGENTS.md"],
|
||||
"docs(agents): refresh State Hub access map from repo-manager",
|
||||
)
|
||||
except GitError as exc:
|
||||
return CommandResult("failed", evidence, {"message": str(exc)}, cid)
|
||||
return CommandResult("applied", evidence, None, cid)
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
"""Governed file-backed task and daily ad-hoc mutations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager import dual_run, idempotency
|
||||
from repo_manager.commands.workplan import CommandResult, create_workplan
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||
from repo_manager.identifiers import derive_work_record_uuid, load_fleet_namespace
|
||||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block
|
||||
from repo_manager.record_identity import classify_record_id
|
||||
from repo_manager.time import utc_today
|
||||
|
||||
VALID_TASK_STATUSES = frozenset({"wait", "todo", "progress", "done", "cancel"})
|
||||
VALID_TASK_PRIORITIES = frozenset({"low", "medium", "high", "critical"})
|
||||
|
||||
|
||||
def _result(
|
||||
command: str,
|
||||
status: str,
|
||||
correlation_id: str,
|
||||
*,
|
||||
evidence: dict[str, Any] | None = None,
|
||||
code: str | None = None,
|
||||
message: str | None = None,
|
||||
) -> CommandResult:
|
||||
error = None
|
||||
if code and message:
|
||||
error = {"code": code, "message": message, "correlation_id": correlation_id}
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status=status,
|
||||
correlation_id=correlation_id,
|
||||
evidence=evidence or {"status": status},
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _is_uuid(value: str) -> bool:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _find_record(index: Any, kind: str, identifier: str) -> Any | None:
|
||||
by_uuid = _is_uuid(identifier)
|
||||
for record in index.work_records:
|
||||
if record.kind != kind:
|
||||
continue
|
||||
if not by_uuid and record.id == identifier:
|
||||
return record
|
||||
if by_uuid and record.uuid and record.uuid.lower() == identifier.lower():
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def _render_scalar(key: str, value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if key in {"status", "priority"}:
|
||||
return str(value)
|
||||
return json.dumps(str(value), ensure_ascii=False)
|
||||
|
||||
|
||||
def _patch_task_fields(
|
||||
text: str,
|
||||
*,
|
||||
canonical_id: str,
|
||||
updates: dict[str, Any],
|
||||
) -> tuple[str, bool]:
|
||||
matches = 0
|
||||
changed = False
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
nonlocal matches, changed
|
||||
raw = match.group(1)
|
||||
meta = _parse_yaml_block(raw.strip())
|
||||
if str(meta.get("id") or "") != canonical_id:
|
||||
return match.group(0)
|
||||
matches += 1
|
||||
patched = raw
|
||||
for key, value in updates.items():
|
||||
rendered = _render_scalar(key, value)
|
||||
pattern = re.compile(rf"^(?P<prefix>{re.escape(key)}:\s*).*$", re.MULTILINE)
|
||||
if pattern.search(patched):
|
||||
candidate = pattern.sub(rf"\g<prefix>{rendered}", patched, count=1)
|
||||
else:
|
||||
candidate = f"{patched.rstrip()}\n{key}: {rendered}\n"
|
||||
if candidate != patched:
|
||||
changed = True
|
||||
patched = candidate
|
||||
return f"```task\n{patched.strip()}\n```"
|
||||
|
||||
result = _TASK_BLOCK_RE.sub(replace, text)
|
||||
if matches != 1:
|
||||
raise ValueError(f"{canonical_id}: expected exactly one task block, found {matches}")
|
||||
return result, changed
|
||||
|
||||
|
||||
def _next_task_id(parent_id: str, index: Any) -> str:
|
||||
pattern = re.compile(rf"^{re.escape(parent_id)}-T(?P<number>[0-9]{{2,3}})$")
|
||||
numbers = [
|
||||
int(match.group("number"))
|
||||
for record in index.work_records
|
||||
if record.kind == "task" and record.id and (match := pattern.fullmatch(record.id))
|
||||
]
|
||||
number = max(numbers, default=0) + 1
|
||||
width = 2 if number < 100 else 3
|
||||
candidate = f"{parent_id}-T{number:0{width}d}"
|
||||
if classify_record_id("task", candidate) != "canonical":
|
||||
raise ValueError(f"no canonical task identifier remains under {parent_id}")
|
||||
return candidate
|
||||
|
||||
|
||||
def mutate_task(
|
||||
repo_root: Path,
|
||||
task_id: str | None,
|
||||
*,
|
||||
operation: str,
|
||||
workplan_id: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
needs_human: bool | None = None,
|
||||
intervention_note: str | None = None,
|
||||
blocking_reason: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr command",
|
||||
commit: bool = True,
|
||||
push: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> CommandResult:
|
||||
command = f"repo.work.{operation}_task"
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
repo_root = repo_root.resolve()
|
||||
payload = {
|
||||
"repo_root": str(repo_root),
|
||||
"task_id": task_id,
|
||||
"operation": operation,
|
||||
"workplan_id": workplan_id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"needs_human": needs_human,
|
||||
"intervention_note": intervention_note,
|
||||
"blocking_reason": blocking_reason,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
}
|
||||
payload_hash = idempotency.payload_hash(payload)
|
||||
if idempotency_key and (prior := idempotency.get(idempotency_key)):
|
||||
if prior.get("payload_hash") != payload_hash:
|
||||
return _result(
|
||||
command, "rejected", correlation_id, code="conflict",
|
||||
message="idempotency key reused with different payload",
|
||||
)
|
||||
previous = prior.get("result") or {}
|
||||
return CommandResult(
|
||||
command=previous.get("command", command),
|
||||
status=previous.get("status", "applied"),
|
||||
correlation_id=previous.get("correlation_id", correlation_id),
|
||||
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
|
||||
error=previous.get("error"),
|
||||
)
|
||||
|
||||
if operation not in {"add", "update"}:
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message="invalid task operation")
|
||||
if status is not None and status not in VALID_TASK_STATUSES:
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message=f"invalid task status {status!r}")
|
||||
if priority is not None and priority not in VALID_TASK_PRIORITIES:
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message=f"invalid task priority {priority!r}")
|
||||
if needs_human is True and not intervention_note:
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message="intervention_note is required when needs_human is true")
|
||||
|
||||
current_head = head_sha(repo_root)
|
||||
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||
result = _result(
|
||||
command, "rejected", correlation_id,
|
||||
evidence={"status": "rejected", "head_sha": current_head},
|
||||
code="precondition_failed",
|
||||
message=f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||
)
|
||||
if result.error:
|
||||
result.error["retryable"] = True
|
||||
return result
|
||||
|
||||
snapshot, index = observe_repository(repo_root, slug=repo_slug)
|
||||
slug = repo_slug or snapshot.get("slug")
|
||||
if operation == "add":
|
||||
if not workplan_id:
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message="add requires workplan_id")
|
||||
parent = _find_record(index, "workplan", workplan_id)
|
||||
if parent is None or not parent.id:
|
||||
return _result(command, "rejected", correlation_id, code="not_found", message=f"workplan {workplan_id!r} not found")
|
||||
if not title or not title.strip():
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message="add requires a title")
|
||||
canonical_id = task_id or _next_task_id(parent.id, index)
|
||||
if classify_record_id("task", canonical_id) != "canonical":
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message=f"invalid canonical task id {canonical_id!r}")
|
||||
if _find_record(index, "task", canonical_id) is not None:
|
||||
return _result(command, "rejected", correlation_id, code="conflict", message=f"task {canonical_id!r} exists")
|
||||
if not canonical_id.startswith(parent.id + "-T"):
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message="task id must be qualified by its workplan id")
|
||||
path = repo_root / parent.source_path
|
||||
task_uuid = derive_work_record_uuid(load_fleet_namespace(), canonical_id)
|
||||
task_status = status or "todo"
|
||||
task_priority = priority or "medium"
|
||||
fields = [
|
||||
f"id: {canonical_id}",
|
||||
f"status: {task_status}",
|
||||
f"priority: {task_priority}",
|
||||
f'state_hub_task_id: "{task_uuid}"',
|
||||
]
|
||||
if needs_human is not None:
|
||||
fields.append(f"needs_human: {'true' if needs_human else 'false'}")
|
||||
if intervention_note:
|
||||
fields.append(f"intervention_note: {json.dumps(intervention_note, ensure_ascii=False)}")
|
||||
if blocking_reason:
|
||||
fields.append(f"blocking_reason: {json.dumps(blocking_reason, ensure_ascii=False)}")
|
||||
addition = (
|
||||
f"\n\n## {title.strip()}\n\n```task\n"
|
||||
+ "\n".join(fields)
|
||||
+ "\n```\n\n"
|
||||
+ ((description or "").strip() or "Task details to be completed.")
|
||||
+ "\n"
|
||||
)
|
||||
path.write_text(path.read_text(encoding="utf-8").rstrip() + addition, encoding="utf-8")
|
||||
changes = {"operation": "add", "task_id": canonical_id, "workplan_id": parent.id}
|
||||
else:
|
||||
if not task_id:
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message="update requires task_id")
|
||||
record = _find_record(index, "task", task_id)
|
||||
if record is None or not record.id:
|
||||
return _result(command, "rejected", correlation_id, code="not_found", message=f"task {task_id!r} not found")
|
||||
updates = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"title": title.strip() if title else None,
|
||||
"description": description.strip() if description else None,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"needs_human": needs_human,
|
||||
"intervention_note": intervention_note,
|
||||
"blocking_reason": blocking_reason,
|
||||
}.items()
|
||||
if value is not None
|
||||
}
|
||||
if not updates:
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message="update requires at least one field")
|
||||
existing_human = bool(record.extra.get("needs_human"))
|
||||
resulting_human = updates.get("needs_human", existing_human)
|
||||
resulting_note = updates.get("intervention_note", record.extra.get("intervention_note"))
|
||||
if resulting_human and not resulting_note:
|
||||
return _result(command, "rejected", correlation_id, code="validation_error", message="intervention_note is required when needs_human is true")
|
||||
path = repo_root / record.source_path
|
||||
patched, changed = _patch_task_fields(
|
||||
path.read_text(encoding="utf-8"), canonical_id=record.id, updates=updates
|
||||
)
|
||||
if not changed:
|
||||
result = _result(
|
||||
command, "applied", correlation_id,
|
||||
evidence={"status": "applied", "git_sha": current_head, "files_touched": [], "noop": True},
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, payload_hash, result.to_dict())
|
||||
return result
|
||||
path.write_text(patched, encoding="utf-8")
|
||||
canonical_id = record.id
|
||||
changes = {"operation": "update", "task_id": canonical_id, "fields": sorted(updates)}
|
||||
|
||||
relative_path = str(path.relative_to(repo_root))
|
||||
git_sha = None
|
||||
push_ok = None
|
||||
push_message = None
|
||||
if commit:
|
||||
try:
|
||||
git_sha = commit_paths(
|
||||
repo_root,
|
||||
[relative_path],
|
||||
message=(
|
||||
f"{command} {canonical_id}\n\ncorrelation_id: {correlation_id}\n"
|
||||
f"reason: {reason}\nsource: repo-manager\n"
|
||||
),
|
||||
)
|
||||
except GitError as exc:
|
||||
return _result(command, "failed", correlation_id, evidence={"status": "failed", "files_touched": [relative_path]}, code="internal", message=str(exc))
|
||||
if push:
|
||||
push_ok, push_message = push_ff(repo_root)
|
||||
|
||||
_snapshot_after, index_after = observe_repository(repo_root, slug=slug)
|
||||
append_event(
|
||||
index_after,
|
||||
{
|
||||
"type": "repo.command.applied",
|
||||
"command": command,
|
||||
"correlation_id": correlation_id,
|
||||
"git_sha": git_sha,
|
||||
**changes,
|
||||
},
|
||||
)
|
||||
save_index(index_after)
|
||||
dual_run.record_mutation(
|
||||
source="repo-manager",
|
||||
kind=f"task_{operation}",
|
||||
repo_slug=slug,
|
||||
detail={**changes, "git_sha": git_sha, "correlation_id": correlation_id},
|
||||
)
|
||||
evidence = {
|
||||
"status": "applied",
|
||||
"task_id": canonical_id,
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [relative_path],
|
||||
"index_path": str(default_index_path(repo_root)),
|
||||
"source": "repo-manager",
|
||||
}
|
||||
if push:
|
||||
evidence.update({"push_ok": push_ok, "push_message": push_message})
|
||||
result = _result(command, "applied", correlation_id, evidence=evidence)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, payload_hash, result.to_dict())
|
||||
return result
|
||||
|
||||
|
||||
def add_adhoc_task(
|
||||
repo_root: Path,
|
||||
title: str,
|
||||
description: str,
|
||||
*,
|
||||
prefix: str | None = None,
|
||||
on_date: date | None = None,
|
||||
status: str = "todo",
|
||||
priority: str = "medium",
|
||||
owner: str = "codex",
|
||||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr adhoc add",
|
||||
commit: bool = True,
|
||||
push: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Create/reuse today's qualified ad-hoc workplan and append one task."""
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
repo_root = repo_root.resolve()
|
||||
payload = {
|
||||
"repo_root": str(repo_root), "title": title, "description": description,
|
||||
"prefix": prefix, "on_date": str(on_date) if on_date else None,
|
||||
"status": status, "priority": priority, "expected_head_sha": expected_head_sha,
|
||||
}
|
||||
payload_hash = idempotency.payload_hash(payload)
|
||||
if idempotency_key and (prior := idempotency.get(idempotency_key)):
|
||||
if prior.get("payload_hash") != payload_hash:
|
||||
return _result("repo.work.add_adhoc_task", "rejected", correlation_id, code="conflict", message="idempotency key reused with different payload")
|
||||
previous = prior.get("result") or {}
|
||||
return CommandResult(
|
||||
command=previous.get("command", "repo.work.add_adhoc_task"),
|
||||
status=previous.get("status", "applied"),
|
||||
correlation_id=previous.get("correlation_id", correlation_id),
|
||||
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
|
||||
error=previous.get("error"),
|
||||
)
|
||||
|
||||
snapshot, index = observe_repository(repo_root, slug=repo_slug)
|
||||
prefixes = {
|
||||
record.id.split("-WP-", 1)[0]
|
||||
for record in index.work_records
|
||||
if record.kind == "workplan" and record.id and "-WP-" in record.id
|
||||
}
|
||||
selected = (prefix or "").strip().upper().removesuffix("-WP")
|
||||
if not selected:
|
||||
if len(prefixes) != 1:
|
||||
return _result("repo.work.add_adhoc_task", "rejected", correlation_id, code="validation_error", message=f"could not infer one workplan prefix; candidates={sorted(prefixes)!r}; pass --prefix")
|
||||
selected = next(iter(prefixes))
|
||||
day = on_date or utc_today()
|
||||
workplan_id = f"{selected}-WP-ADHOC-{day.isoformat()}"
|
||||
parent = _find_record(index, "workplan", workplan_id)
|
||||
if parent is None:
|
||||
created = create_workplan(
|
||||
repo_root,
|
||||
workplan_id,
|
||||
f"Ad Hoc — {day.isoformat()}",
|
||||
f"Small opportunistic fixes completed on {day.isoformat()}.",
|
||||
owner=owner,
|
||||
status="active",
|
||||
commit=False,
|
||||
expected_head_sha=expected_head_sha,
|
||||
repo_slug=repo_slug or snapshot.get("slug"),
|
||||
)
|
||||
if created.status != "applied":
|
||||
return created
|
||||
result = mutate_task(
|
||||
repo_root,
|
||||
None,
|
||||
operation="add",
|
||||
workplan_id=workplan_id,
|
||||
title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
priority=priority,
|
||||
correlation_id=correlation_id,
|
||||
reason=reason,
|
||||
commit=commit,
|
||||
push=push,
|
||||
expected_head_sha=expected_head_sha,
|
||||
repo_slug=repo_slug or snapshot.get("slug"),
|
||||
)
|
||||
result.command = "repo.work.add_adhoc_task"
|
||||
result.evidence["workplan_id"] = workplan_id
|
||||
if idempotency_key and result.status == "applied":
|
||||
idempotency.put(idempotency_key, payload_hash, result.to_dict())
|
||||
return result
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
"""Governed command: repo.work.update_task_status (dual-run hardened)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager import dual_run, idempotency
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block
|
||||
|
||||
VALID_TASK_STATUSES = frozenset({"wait", "todo", "progress", "done", "cancel"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
status: str # applied | rejected | failed
|
||||
evidence: dict[str, Any]
|
||||
error: dict[str, Any] | None = None
|
||||
correlation_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"command": "repo.work.update_task_status",
|
||||
"status": self.status,
|
||||
"correlation_id": self.correlation_id,
|
||||
"evidence": self.evidence,
|
||||
}
|
||||
if self.error:
|
||||
out["error"] = self.error
|
||||
return out
|
||||
|
||||
|
||||
def _is_uuid(value: str) -> bool:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _patch_task_status_in_file(
|
||||
path: Path,
|
||||
*,
|
||||
canonical_id: str | None,
|
||||
hub_uuid: str | None,
|
||||
status: str,
|
||||
) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
block = match.group(0)
|
||||
meta = _parse_yaml_block(match.group(1))
|
||||
tid = str(meta["id"]) if meta.get("id") is not None else None
|
||||
hub = (
|
||||
str(meta["state_hub_task_id"]).strip().strip('"')
|
||||
if meta.get("state_hub_task_id") is not None
|
||||
else None
|
||||
)
|
||||
matched = False
|
||||
if canonical_id and tid == canonical_id:
|
||||
matched = True
|
||||
if hub_uuid and hub and hub.lower() == hub_uuid.lower():
|
||||
matched = True
|
||||
if not matched:
|
||||
return block
|
||||
replaced = re.sub(
|
||||
r"^(status:\s*)\S+",
|
||||
rf"\g<1>{status}",
|
||||
block,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if replaced != block:
|
||||
return replaced
|
||||
return block.replace("\n```", f"\nstatus: {status}\n```", 1)
|
||||
|
||||
new_text = _TASK_BLOCK_RE.sub(_replace, text)
|
||||
if new_text == text:
|
||||
return False
|
||||
path.write_text(new_text, encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def update_task_status(
|
||||
repo_root: Path,
|
||||
task_id: str,
|
||||
status: str,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr command",
|
||||
commit: bool = True,
|
||||
push: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Apply task status change to the workplan file and optionally git-commit.
|
||||
|
||||
``task_id`` may be a canonical id (e.g. RMGR-WP-0001-T05) or a State Hub
|
||||
task UUID (matched against ``state_hub_task_id`` in the file).
|
||||
"""
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
repo_root = repo_root.resolve()
|
||||
payload = {
|
||||
"repo_root": str(repo_root),
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
}
|
||||
ph = idempotency.payload_hash(payload)
|
||||
|
||||
if idempotency_key:
|
||||
prior = idempotency.get(idempotency_key)
|
||||
if prior:
|
||||
if prior.get("payload_hash") != ph:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected"},
|
||||
error={
|
||||
"code": "conflict",
|
||||
"message": "idempotency key reused with different payload",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
# Replay prior result
|
||||
prev = prior.get("result") or {}
|
||||
return CommandResult(
|
||||
status=prev.get("status", "applied"),
|
||||
correlation_id=prev.get("correlation_id", correlation_id),
|
||||
evidence=prev.get("evidence") or {"status": "applied", "replay": True},
|
||||
error=prev.get("error"),
|
||||
)
|
||||
|
||||
if status not in VALID_TASK_STATUSES:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected"},
|
||||
error={
|
||||
"code": "validation_error",
|
||||
"message": f"invalid task status {status!r}",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
current_head = head_sha(repo_root)
|
||||
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected", "head_sha": current_head},
|
||||
error={
|
||||
"code": "precondition_failed",
|
||||
"message": f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||
"correlation_id": correlation_id,
|
||||
"retryable": True,
|
||||
},
|
||||
)
|
||||
|
||||
snapshot, index = observe_repository(repo_root, slug=repo_slug)
|
||||
slug = repo_slug or snapshot.get("slug")
|
||||
|
||||
hub_uuid = task_id if _is_uuid(task_id) else None
|
||||
canonical = None if hub_uuid else task_id
|
||||
|
||||
match = None
|
||||
for r in index.work_records:
|
||||
if r.kind != "task":
|
||||
continue
|
||||
if canonical and r.id == canonical:
|
||||
match = r
|
||||
break
|
||||
if hub_uuid and r.uuid and r.uuid.lower() == hub_uuid.lower():
|
||||
match = r
|
||||
break
|
||||
|
||||
if match is None:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected"},
|
||||
error={
|
||||
"code": "not_found",
|
||||
"message": f"task {task_id!r} not found in workplans",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
path = repo_root / match.source_path
|
||||
if not path.is_file():
|
||||
return CommandResult(
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "failed"},
|
||||
error={
|
||||
"code": "not_found",
|
||||
"message": f"source file missing: {match.source_path}",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Already at status → treat as successful no-op applied with current head
|
||||
if match.status == status:
|
||||
result = CommandResult(
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence={
|
||||
"status": "applied",
|
||||
"git_sha": current_head,
|
||||
"files_touched": [],
|
||||
"noop": True,
|
||||
"observed_at": index.observed_at,
|
||||
},
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, ph, result.to_dict())
|
||||
return result
|
||||
|
||||
changed = _patch_task_status_in_file(
|
||||
path,
|
||||
canonical_id=match.id,
|
||||
hub_uuid=match.uuid or hub_uuid,
|
||||
status=status,
|
||||
)
|
||||
if not changed:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected", "files_touched": []},
|
||||
error={
|
||||
"code": "conflict",
|
||||
"message": "task block not patched (id mismatch)",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
git_sha: str | None = None
|
||||
push_ok: bool | None = None
|
||||
push_msg: str | None = None
|
||||
if commit:
|
||||
try:
|
||||
git_sha = commit_paths(
|
||||
repo_root,
|
||||
[match.source_path],
|
||||
message=(
|
||||
f"repo.work.update_task_status {match.id or task_id} -> {status}\n\n"
|
||||
f"correlation_id: {correlation_id}\nreason: {reason}\n"
|
||||
f"source: repo-manager\n"
|
||||
),
|
||||
)
|
||||
except GitError as exc:
|
||||
return CommandResult(
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={
|
||||
"status": "failed",
|
||||
"files_touched": [match.source_path],
|
||||
"git_sha": None,
|
||||
},
|
||||
error={
|
||||
"code": "internal",
|
||||
"message": str(exc),
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
if push:
|
||||
push_ok, push_msg = push_ff(repo_root)
|
||||
|
||||
_snap2, index2 = observe_repository(repo_root, slug=slug)
|
||||
event = {
|
||||
"type": "repo.command.applied",
|
||||
"command": "repo.work.update_task_status",
|
||||
"correlation_id": correlation_id,
|
||||
"task_id": match.id,
|
||||
"task_uuid": match.uuid,
|
||||
"new_status": status,
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [match.source_path],
|
||||
"expected_head_sha_before": current_head,
|
||||
"source": "repo-manager",
|
||||
}
|
||||
append_event(index2, event)
|
||||
append_event(
|
||||
index2,
|
||||
{
|
||||
"type": "repo.work.indexed",
|
||||
"correlation_id": correlation_id,
|
||||
"kind": "task",
|
||||
"id": match.id,
|
||||
"source_path": match.source_path,
|
||||
},
|
||||
)
|
||||
save_index(index2)
|
||||
|
||||
dual_run.record_mutation(
|
||||
source="repo-manager",
|
||||
kind="task_status_writeback",
|
||||
repo_slug=slug,
|
||||
detail={
|
||||
"task_id": match.id,
|
||||
"status": status,
|
||||
"git_sha": git_sha,
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
evidence: dict[str, Any] = {
|
||||
"status": "applied",
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [match.source_path],
|
||||
"observed_at": index2.observed_at,
|
||||
"index_path": str(default_index_path(repo_root)),
|
||||
"source": "repo-manager",
|
||||
}
|
||||
if push:
|
||||
evidence["push_ok"] = push_ok
|
||||
evidence["push_message"] = push_msg
|
||||
|
||||
if commit and not git_sha:
|
||||
return CommandResult(
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence=evidence,
|
||||
error={
|
||||
"code": "internal",
|
||||
"message": "applied file mutation without git_sha",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
result = CommandResult(
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, ph, result.to_dict())
|
||||
return result
|
||||
|
|
@ -0,0 +1,519 @@
|
|||
"""Governed workplan create, update, and archive commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager import dual_run, idempotency
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||
from repo_manager.identifiers import derive_work_record_uuid, load_fleet_namespace
|
||||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.record_identity import classify_record_id
|
||||
from repo_manager.time import utc_today
|
||||
|
||||
VALID_WORKPLAN_STATUSES = frozenset(
|
||||
{"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"}
|
||||
)
|
||||
_FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P<meta>.*?)\r?\n---(?P<body>\r?\n.*)?\Z", re.DOTALL)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
command: str
|
||||
status: str # applied | rejected | failed
|
||||
evidence: dict[str, Any]
|
||||
error: dict[str, Any] | None = None
|
||||
correlation_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"command": self.command,
|
||||
"status": self.status,
|
||||
"correlation_id": self.correlation_id,
|
||||
"evidence": self.evidence,
|
||||
}
|
||||
if self.error:
|
||||
out["error"] = self.error
|
||||
return out
|
||||
|
||||
|
||||
def _result_from_prior(command: str, prior: dict[str, Any], correlation_id: str) -> CommandResult:
|
||||
previous = prior.get("result") or {}
|
||||
return CommandResult(
|
||||
command=previous.get("command", command),
|
||||
status=previous.get("status", "applied"),
|
||||
correlation_id=previous.get("correlation_id", correlation_id),
|
||||
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
|
||||
error=previous.get("error"),
|
||||
)
|
||||
|
||||
|
||||
def _rejected(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected", **evidence},
|
||||
error={"code": code, "message": message, "correlation_id": correlation_id},
|
||||
)
|
||||
|
||||
|
||||
def _is_uuid(value: str) -> bool:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "workplan"
|
||||
|
||||
|
||||
def _quoted(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return utc_today()
|
||||
|
||||
|
||||
def _patch_frontmatter(text: str, updates: dict[str, str]) -> str | None:
|
||||
match = _FRONTMATTER_RE.match(text)
|
||||
if not match:
|
||||
return None
|
||||
meta = match.group("meta")
|
||||
for key, value in updates.items():
|
||||
rendered = value if key == "status" else _quoted(value)
|
||||
pattern = re.compile(rf"^({re.escape(key)}:\s*).*$", re.MULTILINE)
|
||||
if pattern.search(meta):
|
||||
meta = pattern.sub(rf"\g<1>{rendered}", meta, count=1)
|
||||
else:
|
||||
meta = f"{meta.rstrip()}\n{key}: {rendered}"
|
||||
return f"---\n{meta}\n---{match.group('body') or ''}"
|
||||
|
||||
|
||||
def _find_workplan(index: Any, workplan_id: str) -> Any | None:
|
||||
by_uuid = _is_uuid(workplan_id)
|
||||
for record in index.work_records:
|
||||
if record.kind != "workplan":
|
||||
continue
|
||||
if not by_uuid and record.id == workplan_id:
|
||||
return record
|
||||
if by_uuid and record.uuid and record.uuid.lower() == workplan_id.lower():
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def _create_text(
|
||||
*,
|
||||
workplan_id: str,
|
||||
title: str,
|
||||
goal: str,
|
||||
status: str,
|
||||
domain: str,
|
||||
repo_slug: str,
|
||||
owner: str,
|
||||
topic_slug: str,
|
||||
) -> str:
|
||||
today = _today().isoformat()
|
||||
workplan_uuid = derive_work_record_uuid(load_fleet_namespace(), workplan_id)
|
||||
return (
|
||||
"---\n"
|
||||
f"id: {workplan_id}\n"
|
||||
"type: workplan\n"
|
||||
f"title: {_quoted(title)}\n"
|
||||
f"domain: {domain}\n"
|
||||
f"repo: {repo_slug}\n"
|
||||
f"status: {status}\n"
|
||||
f"flavor: planning\n"
|
||||
"depends_on: []\n"
|
||||
f"owner: {owner}\n"
|
||||
f"topic_slug: {topic_slug}\n"
|
||||
f"created: {_quoted(today)}\n"
|
||||
f"updated: {_quoted(today)}\n"
|
||||
f"state_hub_workstream_id: {_quoted(str(workplan_uuid))}\n"
|
||||
"---\n\n"
|
||||
f"# {title}\n\n"
|
||||
"## Goal\n\n"
|
||||
f"{goal.strip()}\n"
|
||||
)
|
||||
|
||||
|
||||
def mutate_workplan(
|
||||
repo_root: Path,
|
||||
workplan_id: str,
|
||||
*,
|
||||
operation: str,
|
||||
title: str | None = None,
|
||||
goal: str | None = None,
|
||||
status: str | None = None,
|
||||
owner: str | None = None,
|
||||
topic_slug: str | None = None,
|
||||
domain: str | None = None,
|
||||
filename: str | None = None,
|
||||
confirm_archive: bool = False,
|
||||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr command",
|
||||
commit: bool = True,
|
||||
push: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Create, update, or archive a file-backed workplan.
|
||||
|
||||
Update/archive accepts a canonical workplan id or State Hub UUID. ``archive``
|
||||
is the recoverable implementation of the legacy delete operation.
|
||||
"""
|
||||
commands = {
|
||||
"create": "repo.work.create_workplan",
|
||||
"update": "repo.work.update_workplan",
|
||||
"archive": "repo.work.archive_workplan",
|
||||
}
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
command = commands.get(operation, "repo.work.update_workplan")
|
||||
repo_root = repo_root.resolve()
|
||||
payload = {
|
||||
"repo_root": str(repo_root),
|
||||
"workplan_id": workplan_id,
|
||||
"operation": operation,
|
||||
"title": title,
|
||||
"goal": goal,
|
||||
"status": status,
|
||||
"owner": owner,
|
||||
"topic_slug": topic_slug,
|
||||
"domain": domain,
|
||||
"filename": filename,
|
||||
"confirm_archive": confirm_archive,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
}
|
||||
payload_digest = idempotency.payload_hash(payload)
|
||||
|
||||
if idempotency_key:
|
||||
prior = idempotency.get(idempotency_key)
|
||||
if prior:
|
||||
if prior.get("payload_hash") != payload_digest:
|
||||
return _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"conflict",
|
||||
"idempotency key reused with different payload",
|
||||
)
|
||||
return _result_from_prior(command, prior, correlation_id)
|
||||
|
||||
if operation not in commands:
|
||||
return _rejected(command, correlation_id, "validation_error", f"invalid operation {operation!r}")
|
||||
if status is not None and status not in VALID_WORKPLAN_STATUSES:
|
||||
return _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
f"invalid workplan status {status!r}",
|
||||
)
|
||||
if operation == "archive" and not confirm_archive:
|
||||
return _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"confirmation_required",
|
||||
"archive requires confirm_archive=True",
|
||||
)
|
||||
|
||||
current_head = head_sha(repo_root)
|
||||
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||
result = _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"precondition_failed",
|
||||
f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||
head_sha=current_head,
|
||||
)
|
||||
if result.error:
|
||||
result.error["retryable"] = True
|
||||
return result
|
||||
|
||||
snapshot, index = observe_repository(repo_root, slug=repo_slug)
|
||||
slug = repo_slug or snapshot.get("slug")
|
||||
record = _find_workplan(index, workplan_id)
|
||||
touched: list[str]
|
||||
changes: dict[str, Any]
|
||||
|
||||
if operation == "create":
|
||||
if classify_record_id("workplan", workplan_id) != "canonical":
|
||||
return _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
f"invalid canonical workplan id {workplan_id!r}",
|
||||
)
|
||||
if record is not None:
|
||||
return _rejected(command, correlation_id, "conflict", f"workplan {workplan_id!r} exists")
|
||||
if not title or not title.strip() or not goal or not goal.strip():
|
||||
return _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
"create requires non-empty title and goal",
|
||||
)
|
||||
create_status = status or "proposed"
|
||||
if create_status not in VALID_WORKPLAN_STATUSES:
|
||||
return _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
f"invalid workplan status {create_status!r}",
|
||||
)
|
||||
is_adhoc = "-WP-ADHOC-" in workplan_id
|
||||
adhoc_date = workplan_id.rsplit("-WP-ADHOC-", 1)[-1]
|
||||
name = filename or (
|
||||
f"ADHOC-{adhoc_date}.md"
|
||||
if is_adhoc
|
||||
else f"{workplan_id}-{_slugify(title)}.md"
|
||||
)
|
||||
expected_adhoc_name = f"ADHOC-{adhoc_date}.md"
|
||||
safe_name = (
|
||||
Path(name).name == name
|
||||
and name.endswith(".md")
|
||||
and (
|
||||
name.startswith(f"{workplan_id}-")
|
||||
or (is_adhoc and name == expected_adhoc_name)
|
||||
)
|
||||
)
|
||||
if not safe_name:
|
||||
return _rejected(command, correlation_id, "validation_error", "unsafe workplan filename")
|
||||
path = repo_root / "workplans" / name
|
||||
if path.exists():
|
||||
return _rejected(command, correlation_id, "conflict", f"source file exists: workplans/{name}")
|
||||
classification = snapshot.get("classification") or {}
|
||||
create_domain = domain or classification.get("domain") or "infotech"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
_create_text(
|
||||
workplan_id=workplan_id,
|
||||
title=title.strip(),
|
||||
goal=goal,
|
||||
status=create_status,
|
||||
domain=create_domain,
|
||||
repo_slug=slug,
|
||||
owner=owner or "codex",
|
||||
topic_slug=topic_slug or create_domain,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
touched = [str(path.relative_to(repo_root))]
|
||||
changes = {"title": title.strip(), "status": create_status}
|
||||
else:
|
||||
if record is None:
|
||||
return _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"not_found",
|
||||
f"workplan {workplan_id!r} not found in workplans",
|
||||
)
|
||||
path = repo_root / record.source_path
|
||||
if not path.is_file():
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "failed"},
|
||||
error={
|
||||
"code": "not_found",
|
||||
"message": f"source file missing: {record.source_path}",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
if operation == "update":
|
||||
updates = {
|
||||
key: value.strip()
|
||||
for key, value in {
|
||||
"title": title,
|
||||
"status": status,
|
||||
"owner": owner,
|
||||
"topic_slug": topic_slug,
|
||||
"domain": domain,
|
||||
}.items()
|
||||
if value is not None and value.strip()
|
||||
}
|
||||
if not updates:
|
||||
return _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
"update requires at least one field",
|
||||
)
|
||||
changes = {
|
||||
key: value
|
||||
for key, value in updates.items()
|
||||
if key not in {"status", "title"}
|
||||
or (key == "status" and value != record.status)
|
||||
or (key == "title" and value != record.title)
|
||||
}
|
||||
if not changes:
|
||||
result = CommandResult(
|
||||
command=command,
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence={
|
||||
"status": "applied",
|
||||
"git_sha": current_head,
|
||||
"files_touched": [],
|
||||
"noop": True,
|
||||
"observed_at": index.observed_at,
|
||||
},
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, payload_digest, result.to_dict())
|
||||
return result
|
||||
changes["updated"] = _today().isoformat()
|
||||
new_text = _patch_frontmatter(path.read_text(encoding="utf-8"), changes)
|
||||
if new_text is None:
|
||||
return _rejected(command, correlation_id, "conflict", "workplan frontmatter is invalid")
|
||||
path.write_text(new_text, encoding="utf-8")
|
||||
touched = [record.source_path]
|
||||
else:
|
||||
if record.status == "archived" and record.source_path.startswith("workplans/archived/"):
|
||||
result = CommandResult(
|
||||
command=command,
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence={
|
||||
"status": "applied",
|
||||
"git_sha": current_head,
|
||||
"files_touched": [],
|
||||
"noop": True,
|
||||
"observed_at": index.observed_at,
|
||||
},
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, payload_digest, result.to_dict())
|
||||
return result
|
||||
archived_dir = repo_root / "workplans" / "archived"
|
||||
archived_dir.mkdir(parents=True, exist_ok=True)
|
||||
archived_path = archived_dir / f"{_today():%y%m%d}-{path.name}"
|
||||
if archived_path.exists():
|
||||
return _rejected(
|
||||
command,
|
||||
correlation_id,
|
||||
"conflict",
|
||||
f"archive target exists: {archived_path.relative_to(repo_root)}",
|
||||
)
|
||||
new_text = _patch_frontmatter(
|
||||
path.read_text(encoding="utf-8"),
|
||||
{"status": "archived", "updated": _today().isoformat()},
|
||||
)
|
||||
if new_text is None:
|
||||
return _rejected(command, correlation_id, "conflict", "workplan frontmatter is invalid")
|
||||
archived_path.write_text(new_text, encoding="utf-8")
|
||||
path.unlink()
|
||||
touched = [record.source_path, str(archived_path.relative_to(repo_root))]
|
||||
changes = {"status": "archived", "source_path": touched[-1]}
|
||||
|
||||
git_sha: str | None = None
|
||||
push_ok: bool | None = None
|
||||
push_message: str | None = None
|
||||
if commit:
|
||||
try:
|
||||
git_sha = commit_paths(
|
||||
repo_root,
|
||||
touched,
|
||||
message=(
|
||||
f"{command} {workplan_id} ({operation})\n\n"
|
||||
f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n"
|
||||
),
|
||||
)
|
||||
except GitError as exc:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "failed", "files_touched": touched, "git_sha": None},
|
||||
error={"code": "internal", "message": str(exc), "correlation_id": correlation_id},
|
||||
)
|
||||
if push:
|
||||
push_ok, push_message = push_ff(repo_root)
|
||||
|
||||
_snapshot_after, index_after = observe_repository(repo_root, slug=slug)
|
||||
event = {
|
||||
"type": "repo.command.applied",
|
||||
"command": command,
|
||||
"operation": operation,
|
||||
"correlation_id": correlation_id,
|
||||
"workplan_id": record.id if record else workplan_id,
|
||||
"workplan_uuid": record.uuid if record else None,
|
||||
"changes": changes,
|
||||
"git_sha": git_sha,
|
||||
"files_touched": touched,
|
||||
"expected_head_sha_before": current_head,
|
||||
"source": "repo-manager",
|
||||
}
|
||||
append_event(index_after, event)
|
||||
append_event(
|
||||
index_after,
|
||||
{
|
||||
"type": "repo.work.indexed",
|
||||
"correlation_id": correlation_id,
|
||||
"kind": "workplan",
|
||||
"id": record.id if record else workplan_id,
|
||||
"source_path": touched[-1],
|
||||
},
|
||||
)
|
||||
save_index(index_after)
|
||||
dual_run.record_mutation(
|
||||
source="repo-manager",
|
||||
kind=f"workplan_{operation}",
|
||||
repo_slug=slug,
|
||||
detail={
|
||||
"workplan_id": record.id if record else workplan_id,
|
||||
"changes": changes,
|
||||
"git_sha": git_sha,
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
evidence: dict[str, Any] = {
|
||||
"status": "applied",
|
||||
"git_sha": git_sha,
|
||||
"files_touched": touched,
|
||||
"observed_at": index_after.observed_at,
|
||||
"index_path": str(default_index_path(repo_root)),
|
||||
"source": "repo-manager",
|
||||
}
|
||||
if push:
|
||||
evidence["push_ok"] = push_ok
|
||||
evidence["push_message"] = push_message
|
||||
result = CommandResult(
|
||||
command=command,
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, payload_digest, result.to_dict())
|
||||
return result
|
||||
|
||||
|
||||
def create_workplan(repo_root: Path, workplan_id: str, title: str, goal: str, **kwargs: Any) -> CommandResult:
|
||||
return mutate_workplan(
|
||||
repo_root,
|
||||
workplan_id,
|
||||
operation="create",
|
||||
title=title,
|
||||
goal=goal,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def update_workplan(repo_root: Path, workplan_id: str, **kwargs: Any) -> CommandResult:
|
||||
return mutate_workplan(repo_root, workplan_id, operation="update", **kwargs)
|
||||
|
||||
|
||||
def archive_workplan(repo_root: Path, workplan_id: str, **kwargs: Any) -> CommandResult:
|
||||
return mutate_workplan(repo_root, workplan_id, operation="archive", **kwargs)
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
"""Dual-run flags and checkout-mutation meter (RMGR-WP-0002/0003).
|
||||
|
||||
Precedence (highest first):
|
||||
1. Environment variables when set
|
||||
2. Config file (~/.repo-manager/dual-run.yaml or RM_DUAL_RUN_CONFIG)
|
||||
3. Defaults (all off)
|
||||
|
||||
Environment variables:
|
||||
RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH,
|
||||
RM_DUAL_RUN_CONFIG
|
||||
|
||||
Rollback: writeback/reconcile false in config and unset env → State Hub native.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager.time import utc_now_text
|
||||
|
||||
Source = Literal["state-hub", "repo-manager"]
|
||||
|
||||
_TRUE = frozenset({"1", "true", "yes", "on"})
|
||||
_FALSE = frozenset({"0", "false", "no", "off"})
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml")
|
||||
return Path(raw).expanduser()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_file_config() -> dict[str, Any]:
|
||||
path = config_path()
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
"""Clear cached config (tests / SIGHUP-style)."""
|
||||
_load_file_config.cache_clear()
|
||||
|
||||
|
||||
def _env_bool(name: str) -> bool | None:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return None
|
||||
v = raw.strip().lower()
|
||||
if v in _TRUE:
|
||||
return True
|
||||
if v in _FALSE:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _cfg_bool(key: str, default: bool = False) -> bool:
|
||||
env_map = {
|
||||
"writeback": "RM_WRITEBACK",
|
||||
"reconcile": "RM_RECONCILE",
|
||||
"writeback_push": "RM_WRITEBACK_PUSH",
|
||||
}
|
||||
env_name = env_map.get(key)
|
||||
if env_name:
|
||||
ev = _env_bool(env_name)
|
||||
if ev is not None:
|
||||
return ev
|
||||
cfg = _load_file_config()
|
||||
if key in cfg:
|
||||
val = cfg[key]
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in _TRUE
|
||||
return default
|
||||
|
||||
|
||||
def writeback_enabled() -> bool:
|
||||
return _cfg_bool("writeback", False)
|
||||
|
||||
|
||||
def reconcile_enabled() -> bool:
|
||||
return _cfg_bool("reconcile", False)
|
||||
|
||||
|
||||
def writeback_push_enabled() -> bool:
|
||||
return _cfg_bool("writeback_push", False)
|
||||
|
||||
|
||||
def pilot_slugs() -> set[str] | None:
|
||||
"""None = all repos; empty set = none."""
|
||||
raw = os.environ.get("RM_PILOT_REPOS")
|
||||
if raw is not None:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return set()
|
||||
return {s.strip() for s in raw.split(",") if s.strip()}
|
||||
cfg = _load_file_config()
|
||||
if "pilot_repos" not in cfg:
|
||||
return None
|
||||
val = cfg["pilot_repos"]
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
if not val.strip():
|
||||
return set()
|
||||
return {s.strip() for s in val.split(",") if s.strip()}
|
||||
if isinstance(val, list):
|
||||
return {str(s).strip() for s in val if str(s).strip()}
|
||||
return None
|
||||
|
||||
|
||||
def slug_allowed(slug: str | None) -> bool:
|
||||
if not slug:
|
||||
return False
|
||||
pilots = pilot_slugs()
|
||||
if pilots is None:
|
||||
return True
|
||||
return slug in pilots
|
||||
|
||||
|
||||
def writeback_for_repo(slug: str | None) -> bool:
|
||||
return writeback_enabled() and slug_allowed(slug)
|
||||
|
||||
|
||||
def reconcile_for_repo(slug: str | None) -> bool:
|
||||
return reconcile_enabled() and slug_allowed(slug)
|
||||
|
||||
|
||||
def meter_path() -> Path:
|
||||
raw = os.environ.get("RM_METER_PATH")
|
||||
if raw:
|
||||
return Path(raw).expanduser()
|
||||
cfg = _load_file_config()
|
||||
if cfg.get("meter_path"):
|
||||
return Path(str(cfg["meter_path"])).expanduser()
|
||||
return Path("~/.repo-manager/mutation-meter.jsonl").expanduser()
|
||||
|
||||
|
||||
def record_mutation(
|
||||
*,
|
||||
source: Source,
|
||||
kind: str,
|
||||
repo_slug: str | None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
path = meter_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
row = {
|
||||
"ts": utc_now_text(),
|
||||
"source": source,
|
||||
"kind": kind,
|
||||
"repo_slug": repo_slug,
|
||||
"detail": detail or {},
|
||||
}
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(row, default=str) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def meter_summary(path: Path | None = None) -> dict[str, int]:
|
||||
path = path or meter_path()
|
||||
counts: dict[str, int] = {"state-hub": 0, "repo-manager": 0, "total": 0}
|
||||
if not path.is_file():
|
||||
return counts
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
src = row.get("source")
|
||||
if src in counts:
|
||||
counts[src] += 1
|
||||
counts["total"] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def flags_status() -> dict[str, Any]:
|
||||
return {
|
||||
"config_path": str(config_path()),
|
||||
"config_exists": config_path().is_file(),
|
||||
"writeback": writeback_enabled(),
|
||||
"reconcile": reconcile_enabled(),
|
||||
"writeback_push": writeback_push_enabled(),
|
||||
"pilot_repos": sorted(pilot_slugs()) if pilot_slugs() is not None else None,
|
||||
"meter_path": str(meter_path()),
|
||||
"meter": meter_summary(),
|
||||
# legacy env-style keys for operators
|
||||
"RM_WRITEBACK": writeback_enabled(),
|
||||
"RM_RECONCILE": reconcile_enabled(),
|
||||
"RM_WRITEBACK_PUSH": writeback_push_enabled(),
|
||||
"RM_PILOT_REPOS": sorted(pilot_slugs()) if pilot_slugs() is not None else None,
|
||||
"RM_METER_PATH": str(meter_path()),
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
"""Read-only freshness-coverage measurement (STATE-WP-0094-T05 evidence).
|
||||
|
||||
Gathers, from currently supported sources only:
|
||||
|
||||
* State Hub REST (GET only): instance identity, ``/repos/``, per-repository
|
||||
``work-record-projection/snapshot`` (``backing_synced_at``).
|
||||
* Forge (``git ls-remote``, read-only): per-repository source HEAD commit.
|
||||
* Kubernetes (``kubectl get``, read-only): runner images and the live
|
||||
Activity Core definition that would route C-rule runs to Repo Manager.
|
||||
* Local State Hub edge outbox (``statehub outbox status``): queued receipts.
|
||||
|
||||
It never writes to the hub, the cluster, Forge or any checkout, and it does not
|
||||
read workstation checkouts (ADR-002). Fields that no supported source exposes are
|
||||
reported under ``gaps`` instead of being inferred.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m repo_manager.freshness --out docs/evidence/<date>-freshness-coverage.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFINITIONS_CONFIGMAP = ("activity-core", "actcore-external-activity-definitions")
|
||||
C_RULE_QUERY = "consistency_sweep_remote_all"
|
||||
|
||||
|
||||
def _get(api: str, path: str, timeout: float = 30) -> Any:
|
||||
with urllib.request.urlopen(f"{api}{path}", timeout=timeout) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def _run(cmd: list[str], timeout: float = 30) -> tuple[int, str]:
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return 1, str(exc)
|
||||
return proc.returncode, (proc.stdout if proc.returncode == 0 else proc.stderr).strip()
|
||||
|
||||
|
||||
def _parse_ts(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def forge_head(remote_url: str | None) -> dict[str, Any]:
|
||||
if not remote_url:
|
||||
return {"commit": None, "error": "no remote_url"}
|
||||
code, out = _run(["git", "ls-remote", remote_url, "HEAD"], timeout=30)
|
||||
if code != 0 or not out:
|
||||
return {"commit": None, "error": (out or "empty ls-remote")[-200:]}
|
||||
return {"commit": out.split()[0], "error": None}
|
||||
|
||||
|
||||
def projection_sync(api: str, slug: str) -> dict[str, Any]:
|
||||
try:
|
||||
snap = _get(api, f"/repos/{slug}/work-record-projection/snapshot")
|
||||
except Exception as exc: # noqa: BLE001 - evidence collector records any failure
|
||||
return {"error": str(exc)[-200:]}
|
||||
stamps = [w.get("backing_synced_at") for w in snap.get("workplans", [])]
|
||||
stamps = [s for s in stamps if s]
|
||||
return {
|
||||
"error": None,
|
||||
"workplans": len(snap.get("workplans", [])),
|
||||
"tasks": len(snap.get("tasks", [])),
|
||||
"dependencies": len(snap.get("dependencies", [])),
|
||||
"last_backing_synced_at": max(stamps) if stamps else None,
|
||||
}
|
||||
|
||||
|
||||
def live_definitions() -> dict[str, Any]:
|
||||
ns, name = DEFINITIONS_CONFIGMAP
|
||||
code, out = _run(["kubectl", "-n", ns, "get", "configmap", name, "-o", "json"])
|
||||
if code != 0:
|
||||
return {"error": out[-200:], "c_rule_definitions": []}
|
||||
data = json.loads(out).get("data", {})
|
||||
found = []
|
||||
for key, body in sorted(data.items()):
|
||||
if C_RULE_QUERY not in body and "repo-manager" not in body.lower():
|
||||
continue
|
||||
field = {
|
||||
f: (m.group(1).strip().strip('"') if m else None)
|
||||
for f, m in (
|
||||
("id", re.search(r"^\s*id:\s*(.+)$", body, re.MULTILINE)),
|
||||
("version", re.search(r"^\s*version:\s*(.+)$", body, re.MULTILINE)),
|
||||
("enabled", re.search(r"^\s*enabled:\s*(\S+)", body, re.MULTILINE)),
|
||||
("status", re.search(r"^\s*status:\s*(\S+)", body, re.MULTILINE)),
|
||||
("cron", re.search(r"cron_expression:\s*\"?([^\"\n]+)", body)),
|
||||
)
|
||||
}
|
||||
found.append({"key": key, **field})
|
||||
return {"error": None, "configmap": f"{ns}/{name}", "c_rule_definitions": found}
|
||||
|
||||
|
||||
def runner_revisions(repo_root: Path) -> dict[str, Any]:
|
||||
code, head = _run(["git", "-C", str(repo_root), "rev-parse", "HEAD"])
|
||||
images = {}
|
||||
for ns, deploy in (("state-hub", "state-hub"), ("activity-core", "actcore-worker")):
|
||||
c, img = _run(
|
||||
[
|
||||
"kubectl",
|
||||
"-n",
|
||||
ns,
|
||||
"get",
|
||||
"deploy",
|
||||
deploy,
|
||||
"-o",
|
||||
"jsonpath={.spec.template.spec.containers[0].image}",
|
||||
]
|
||||
)
|
||||
images[f"{ns}/{deploy}"] = img if c == 0 else f"unavailable: {img[-120:]}"
|
||||
c, rm = _run(["kubectl", "get", "deploy", "-A", "-o", "name"])
|
||||
rm_deployed = [d for d in rm.splitlines() if "repo-manager" in d] if c == 0 else None
|
||||
return {
|
||||
"repo_manager_source_head": head if code == 0 else None,
|
||||
"repo_manager_cluster_deployments": rm_deployed,
|
||||
"images": images,
|
||||
}
|
||||
|
||||
|
||||
def summarize(rows: list[dict[str, Any]], now: datetime) -> dict[str, Any]:
|
||||
"""Pure summary of per-repository rows (unit-tested)."""
|
||||
active = [r for r in rows if r.get("status") == "active"]
|
||||
ages = []
|
||||
for r in active:
|
||||
ts = _parse_ts(r.get("last_backing_synced_at"))
|
||||
r["age_hours"] = round((now - ts).total_seconds() / 3600, 1) if ts else None
|
||||
if ts:
|
||||
ages.append(r["age_hours"])
|
||||
ages.sort()
|
||||
|
||||
def within(h: float) -> int:
|
||||
return sum(1 for a in ages if a <= h)
|
||||
|
||||
def pct(p: float) -> float | None:
|
||||
if not ages:
|
||||
return None
|
||||
return ages[min(len(ages) - 1, round(p * (len(ages) - 1)))]
|
||||
|
||||
return {
|
||||
"repos_total": len(rows),
|
||||
"repos_active": len(active),
|
||||
"active_with_projection_sync": len(ages),
|
||||
"active_never_synced_or_no_workplans": len(active) - len(ages),
|
||||
"active_forge_head_resolved": sum(1 for r in active if r.get("forge_commit")),
|
||||
"synced_within_24h": within(24),
|
||||
"synced_within_7d": within(24 * 7),
|
||||
"synced_within_30d": within(24 * 30),
|
||||
"age_hours": {
|
||||
"min": ages[0] if ages else None,
|
||||
"median": statistics.median(ages) if ages else None,
|
||||
"p90": pct(0.9),
|
||||
"max": ages[-1] if ages else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def measure(api: str, repo_root: Path, workers: int = 8) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
health = _get(api, "/state/health")
|
||||
repos = _get(api, "/repos/?limit=1000")
|
||||
|
||||
def row(repo: dict[str, Any]) -> dict[str, Any]:
|
||||
forge = forge_head(repo.get("remote_url")) if repo.get("status") == "active" else {}
|
||||
proj = projection_sync(api, repo["slug"])
|
||||
return {
|
||||
"slug": repo["slug"],
|
||||
"status": repo.get("status"),
|
||||
"remote_url": repo.get("remote_url"),
|
||||
"forge_commit": forge.get("commit"),
|
||||
"forge_error": forge.get("error"),
|
||||
"hub_last_state_synced_at": repo.get("last_state_synced_at"),
|
||||
"last_backing_synced_at": proj.get("last_backing_synced_at"),
|
||||
"projection_error": proj.get("error"),
|
||||
"workplans": proj.get("workplans"),
|
||||
"tasks": proj.get("tasks"),
|
||||
"dependencies": proj.get("dependencies"),
|
||||
}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
rows = sorted(pool.map(row, repos), key=lambda r: r["slug"])
|
||||
|
||||
code, outbox = _run(["statehub", "outbox", "status"])
|
||||
try:
|
||||
outbox_status = json.loads(outbox) if code == 0 else {"error": outbox[-200:]}
|
||||
except json.JSONDecodeError:
|
||||
outbox_status = {"error": outbox[-200:]}
|
||||
try:
|
||||
ops_runs = _get(api, "/ops-runs/summary")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
ops_runs = {"error": str(exc)[-200:]}
|
||||
|
||||
return {
|
||||
"schema": "repo-manager.freshness-coverage.v1",
|
||||
"measured_at": now.isoformat().replace("+00:00", "Z"),
|
||||
"hub": {k: health.get(k) for k in ("status", "instance_role", "instance_label")}
|
||||
| {"api_base": api, "schema": health.get("schema")},
|
||||
"runner": runner_revisions(repo_root),
|
||||
"active_definition": live_definitions(),
|
||||
"receipts": {
|
||||
"edge_outbox": outbox_status,
|
||||
"ops_runs_summary": ops_runs,
|
||||
},
|
||||
"summary": summarize(rows, now),
|
||||
"repositories": rows,
|
||||
"gaps": [
|
||||
(
|
||||
"State Hub does not persist per-repository projection receipts or the "
|
||||
"derived/expected source commit; `rmgr sync` returns the receipt to its "
|
||||
"caller only. last_backing_synced_at is the closest supported proxy for "
|
||||
"'last successful receipt' and proves no commit equality with forge_commit."
|
||||
),
|
||||
(
|
||||
"Failed receipts are not persisted anywhere queryable (mutation meter has "
|
||||
"no sync/projection kinds; ops-runs summary depends on ACTIVITY_CORE_URL)."
|
||||
),
|
||||
(
|
||||
"No supported scheduled C-rule runner exists; freshness is driven only by "
|
||||
"manual/agent-initiated syncs, so the window below is observed, not an SLA."
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--api-base", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--repo-root", default=str(Path(__file__).resolve().parents[2]))
|
||||
parser.add_argument("--out", default=None)
|
||||
args = parser.parse_args(argv)
|
||||
result = measure(args.api_base.rstrip("/"), Path(args.repo_root))
|
||||
text = json.dumps(result, indent=2, sort_keys=False) + "\n"
|
||||
if args.out:
|
||||
Path(args.out).write_text(text)
|
||||
print(json.dumps(result["summary"], indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"""Durable freshness receipts (RMGR-WP-0020-T01).
|
||||
|
||||
Contract ``repo-manager.freshness-receipt.v1``: one immutable JSON object per
|
||||
repository attempt of the bounded freshness runner. It records the repository,
|
||||
the Forge commit the runner read, the expected/derived/projected commits bound by
|
||||
State Hub's ``state-hub.repository-projection-reconcile.v1`` receipt, the
|
||||
outcome, the reason, the runner revision, the Activity Core definition and fire,
|
||||
timings, and the verified primary-hub identity.
|
||||
|
||||
Store: an append-only JSON Lines file in the Repo Manager evidence sink, next to
|
||||
the mutation meter (``~/.repo-manager/mutation-meter.jsonl``) and the
|
||||
idempotency store. This is repo-manager's existing persistence pattern. The
|
||||
State Hub DB is deliberately not used, because the hub is the system under
|
||||
measurement and is a read model. The file needs no new service, it is readable
|
||||
with ``jq``/``rmgr freshness receipts``, and appends are serialised with an
|
||||
advisory lock and fsynced. A deployed runner must mount this path on a
|
||||
persistent volume (T04).
|
||||
|
||||
Path resolution: ``RM_FRESHNESS_RECEIPTS_PATH`` env, then
|
||||
``freshness_receipts_path`` in the dual-run config file, then the default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager.dual_run import _load_file_config
|
||||
|
||||
SCHEMA = "repo-manager.freshness-receipt.v1"
|
||||
DEFAULT_PATH = "~/.repo-manager/freshness-receipts.jsonl"
|
||||
|
||||
# ``noop`` covers both "hub reported nothing to change" and "Forge HEAD already
|
||||
# equals the last successful receipt" (then ``reconciled`` is False).
|
||||
# ``queued`` and ``skipped`` are reserved by the contract; the bounded runner
|
||||
# never queues and does not write receipts for repositories it did not attempt.
|
||||
OUTCOMES = ("applied", "noop", "queued", "failed", "refused", "skipped")
|
||||
SUCCESS_OUTCOMES = frozenset({"applied", "noop"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class HubIdentity:
|
||||
api_base: str
|
||||
instance_role: str | None
|
||||
instance_label: str | None
|
||||
expected_instance_label: str | None
|
||||
verified_primary: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class FreshnessReceipt:
|
||||
receipt_id: str
|
||||
repo_slug: str
|
||||
outcome: str
|
||||
reason: str | None
|
||||
forge_commit: str | None
|
||||
forge_ref: str | None
|
||||
expected_commit: str | None
|
||||
derived_commit: str | None
|
||||
projected_commit: str | None
|
||||
reconciled: bool
|
||||
http_status: int | None
|
||||
hub: HubIdentity
|
||||
runner_revision: str
|
||||
definition_id: str | None
|
||||
definition_version: str | None
|
||||
fire_id: str
|
||||
started_at: str
|
||||
finished_at: str
|
||||
elapsed_ms: float
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
schema: str = SCHEMA
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.outcome not in OUTCOMES:
|
||||
raise ValueError(f"invalid freshness outcome: {self.outcome!r}")
|
||||
if not self.hub.verified_primary:
|
||||
raise ValueError("a freshness receipt requires a verified primary hub")
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
return self.outcome in SUCCESS_OUTCOMES
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def receipts_path() -> Path:
|
||||
raw = os.environ.get("RM_FRESHNESS_RECEIPTS_PATH")
|
||||
if raw:
|
||||
return Path(raw).expanduser()
|
||||
cfg = _load_file_config()
|
||||
if cfg.get("freshness_receipts_path"):
|
||||
return Path(str(cfg["freshness_receipts_path"])).expanduser()
|
||||
return Path(DEFAULT_PATH).expanduser()
|
||||
|
||||
|
||||
def append_receipt(receipt: FreshnessReceipt, path: Path | None = None) -> Path:
|
||||
"""Append one receipt durably (locked, flushed and fsynced)."""
|
||||
path = path or receipts_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(receipt.to_dict(), sort_keys=True, default=str) + "\n"
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
try:
|
||||
handle.write(line)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
finally:
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
return path
|
||||
|
||||
|
||||
def load_receipts(path: Path | None = None, repo_slug: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Read receipts in append order; malformed or foreign lines are ignored."""
|
||||
path = path or receipts_path()
|
||||
if not path.is_file():
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
for raw in handle:
|
||||
try:
|
||||
row = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(row, dict) or row.get("schema") != SCHEMA:
|
||||
continue
|
||||
if repo_slug and row.get("repo_slug") != repo_slug:
|
||||
continue
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReceiptIndex:
|
||||
last: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
last_success: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
def index_receipts(rows: list[dict[str, Any]]) -> ReceiptIndex:
|
||||
"""Latest receipt and latest successful receipt per repository."""
|
||||
index = ReceiptIndex()
|
||||
for row in rows:
|
||||
slug = row.get("repo_slug")
|
||||
if not slug:
|
||||
continue
|
||||
index.last[slug] = row
|
||||
if row.get("outcome") in SUCCESS_OUTCOMES and row.get("forge_commit"):
|
||||
index.last_success[slug] = row
|
||||
return index
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
"""Bounded Forge-sourced freshness runner (RMGR-WP-0020-T02).
|
||||
|
||||
``rmgr freshness run --limit N --max-seconds S [--dry-run]``
|
||||
|
||||
1. Verify that the answering State Hub is the primary (``instance_role`` and
|
||||
``instance_label``). Otherwise refuse the whole run and write no receipts.
|
||||
2. Rank active repositories stalest first. Repositories without a receipt come
|
||||
first, ordered by ``backing_synced_at`` (missing first). Then come the rest,
|
||||
ordered by the time of their latest receipt of any outcome, so that a
|
||||
permanently refused hole cannot starve the rotation.
|
||||
3. For at most N repositories, read the Forge default-branch HEAD over the
|
||||
Forgejo HTTP API. Skip with ``noop`` (no hub request) when it equals the
|
||||
commit of the last successful receipt.
|
||||
4. Otherwise ask State Hub to derive the projection from that immutable Forge
|
||||
commit (``POST /repos/{slug}/work-record-projection/reconcile``,
|
||||
STATE-WP-0086). There is no checkout, no ``--all --remote`` and no
|
||||
``/consistency/sweep/remote-all``. ``acknowledge_retirements`` is always
|
||||
false, so a hub refusal is recorded as ``refused`` and never forced.
|
||||
5. Verify the hub receipt like ``projection_sync``: schema, primary, instance
|
||||
label, and expected == derived == projected == Forge commit.
|
||||
6. Persist one ``repo-manager.freshness-receipt.v1`` per attempted
|
||||
repository. Stop starting work at ``max_seconds``. Each request timeout is
|
||||
clipped to the remaining budget.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from repo_manager import __version__
|
||||
from repo_manager.freshness_receipts import (
|
||||
FreshnessReceipt,
|
||||
HubIdentity,
|
||||
ReceiptIndex,
|
||||
append_receipt,
|
||||
index_receipts,
|
||||
load_receipts,
|
||||
receipts_path,
|
||||
)
|
||||
from repo_manager.time import utc_now_text
|
||||
|
||||
RUN_SCHEMA = "repo-manager.freshness-run.v1"
|
||||
RECONCILE_SCHEMA = "state-hub.repository-projection-reconcile.v1"
|
||||
DEFAULT_FORGE_BASE = "https://forgejo.coulomb.social"
|
||||
DEFAULT_FORGE_OWNER = "coulomb"
|
||||
# Never start a reconcile with less budget than this; the request would only
|
||||
# time out with an unknown server-side outcome.
|
||||
MIN_RECONCILE_SECONDS = 5.0
|
||||
# Share of max_seconds (capped at 60 s) for the pre-receipt backing_synced_at fallback.
|
||||
SNAPSHOT_BUDGET_FRACTION = 0.25
|
||||
|
||||
|
||||
class HubNotPrimary(RuntimeError):
|
||||
def __init__(self, identity: dict[str, Any]):
|
||||
super().__init__("answering State Hub is not the verified primary")
|
||||
self.identity = identity
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunConfig:
|
||||
limit: int
|
||||
max_seconds: float
|
||||
dry_run: bool = False
|
||||
api_base: str = "http://127.0.0.1:8000"
|
||||
expected_instance_label: str = "railiance01"
|
||||
forge_base: str = DEFAULT_FORGE_BASE
|
||||
forge_owner: str = DEFAULT_FORGE_OWNER
|
||||
forge_token: str | None = None
|
||||
receipts_path: Path | None = None
|
||||
runner_revision: str | None = None
|
||||
definition_id: str | None = None
|
||||
definition_version: str | None = None
|
||||
fire_id: str | None = None
|
||||
snapshot_workers: int = 8
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candidate:
|
||||
slug: str
|
||||
has_receipt: bool
|
||||
rank_ts: str | None
|
||||
last_success_commit: str | None = None
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def runner_revision() -> str:
|
||||
"""Source revision of this runner: git HEAD of the package checkout or version."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(root), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, timeout=5, check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
proc = None
|
||||
if proc is not None and proc.returncode == 0 and proc.stdout.strip():
|
||||
return proc.stdout.strip()
|
||||
return f"repo-manager=={__version__}"
|
||||
|
||||
|
||||
def rank_candidates(
|
||||
active_slugs: list[str],
|
||||
index: ReceiptIndex,
|
||||
backing_synced_at: dict[str, str | None],
|
||||
) -> list[Candidate]:
|
||||
"""Pure stalest-first ranking (unit-tested)."""
|
||||
never: list[Candidate] = []
|
||||
seen: list[Candidate] = []
|
||||
for slug in active_slugs:
|
||||
last = index.last.get(slug)
|
||||
success = index.last_success.get(slug)
|
||||
commit = success.get("forge_commit") if success else None
|
||||
if last is None:
|
||||
never.append(Candidate(slug, False, backing_synced_at.get(slug), commit))
|
||||
else:
|
||||
seen.append(Candidate(slug, True, last.get("finished_at"), commit))
|
||||
# ISO-8601 UTC strings sort chronologically; None (never synced) sorts first.
|
||||
never.sort(key=lambda c: (c.rank_ts is not None, c.rank_ts or "", c.slug))
|
||||
seen.sort(key=lambda c: (c.rank_ts or "", c.slug))
|
||||
return never + seen
|
||||
|
||||
|
||||
class _Budget:
|
||||
def __init__(self, max_seconds: float, clock: Callable[[], float]):
|
||||
self.clock = clock
|
||||
self.deadline = clock() + max_seconds
|
||||
|
||||
def remaining(self) -> float:
|
||||
return self.deadline - self.clock()
|
||||
|
||||
|
||||
def _identity(health: dict[str, Any], cfg: RunConfig) -> HubIdentity:
|
||||
role = health.get("instance_role")
|
||||
label = health.get("instance_label")
|
||||
return HubIdentity(
|
||||
api_base=cfg.api_base.rstrip("/"),
|
||||
instance_role=role,
|
||||
instance_label=label,
|
||||
expected_instance_label=cfg.expected_instance_label,
|
||||
verified_primary=role == "primary" and label == cfg.expected_instance_label,
|
||||
)
|
||||
|
||||
|
||||
def _forge_head(forge: httpx.Client, owner: str, slug: str, timeout: float) -> dict[str, Any]:
|
||||
try:
|
||||
repo = forge.get(f"/api/v1/repos/{owner}/{slug}", timeout=timeout)
|
||||
repo.raise_for_status()
|
||||
branch = repo.json().get("default_branch") or "main"
|
||||
resp = forge.get(f"/api/v1/repos/{owner}/{slug}/branches/{branch}", timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
commit = (resp.json().get("commit") or {}).get("id")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return {"commit": None, "ref": None,
|
||||
"error": f"forge HTTP {exc.response.status_code}"}
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
return {"commit": None, "ref": None, "error": f"forge unavailable: {exc}"[:300]}
|
||||
if not isinstance(commit, str) or len(commit) != 40:
|
||||
return {"commit": None, "ref": branch, "error": "forge returned no full commit id"}
|
||||
return {"commit": commit.lower(), "ref": branch, "error": None}
|
||||
|
||||
|
||||
def _backing_synced(hub: httpx.Client, slugs: list[str], workers: int,
|
||||
budget: _Budget) -> dict[str, str | None]:
|
||||
"""Fallback ranking key, bounded to a fraction of the run budget.
|
||||
|
||||
Repositories whose snapshot fails or does not answer in time rank as never
|
||||
synced (first), which is the conservative choice.
|
||||
"""
|
||||
if not slugs:
|
||||
return {}
|
||||
phase = max(min(budget.remaining() * SNAPSHOT_BUDGET_FRACTION, 60.0), 0.5)
|
||||
request_timeout = min(20.0, phase)
|
||||
|
||||
def one(slug: str) -> str | None:
|
||||
try:
|
||||
resp = hub.get(f"/repos/{slug}/work-record-projection/snapshot",
|
||||
timeout=request_timeout)
|
||||
resp.raise_for_status()
|
||||
stamps = [w.get("backing_synced_at") for w in resp.json().get("workplans", [])]
|
||||
except (httpx.HTTPError, ValueError, AttributeError):
|
||||
return None
|
||||
stamps = [s for s in stamps if s]
|
||||
return max(stamps) if stamps else None
|
||||
|
||||
pool = ThreadPoolExecutor(max_workers=max(1, workers))
|
||||
futures = {pool.submit(one, slug): slug for slug in slugs}
|
||||
done, _ = wait(futures, timeout=phase)
|
||||
# Do not wait for stragglers; in-flight requests end within request_timeout.
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
return {slug: (f.result() if f in done else None) for f, slug in futures.items()}
|
||||
|
||||
|
||||
def _verify_reconcile(receipt: Any, identity: HubIdentity, slug: str,
|
||||
commit: str) -> str | None:
|
||||
"""Return why a hub response is not a valid exact-commit primary receipt."""
|
||||
if not isinstance(receipt, dict) or receipt.get("schema") != RECONCILE_SCHEMA:
|
||||
return "unexpected receipt schema"
|
||||
if receipt.get("instance_role") != "primary":
|
||||
return "receipt not issued by a primary hub"
|
||||
if receipt.get("instance_label") != identity.instance_label:
|
||||
return "receipt instance label differs from the verified primary"
|
||||
outcome = receipt.get("outcome")
|
||||
if not isinstance(outcome, dict):
|
||||
return "receipt has no outcome"
|
||||
if not (receipt.get("expected_commit") == commit
|
||||
and receipt.get("derived_commit") == commit
|
||||
and outcome.get("commit") == commit):
|
||||
return "expected/derived/projected commit does not equal the Forge commit"
|
||||
if outcome.get("repo_slug") != slug:
|
||||
return "receipt binds a different repository"
|
||||
if outcome.get("status") not in {"applied", "noop", "refused"}:
|
||||
return f"unexpected outcome status {outcome.get('status')!r}"
|
||||
return None
|
||||
|
||||
|
||||
def _http_error_detail(exc: httpx.HTTPStatusError) -> Any:
|
||||
try:
|
||||
body = exc.response.json()
|
||||
except ValueError:
|
||||
return exc.response.text[:300]
|
||||
return body.get("detail", body) if isinstance(body, dict) else body
|
||||
|
||||
|
||||
def _reconcile(hub: httpx.Client, identity: HubIdentity, slug: str, commit: str,
|
||||
timeout: float) -> dict[str, Any]:
|
||||
"""One immutable-commit reconcile. Returns receipt fields (no store write)."""
|
||||
path = f"/repos/{slug}/work-record-projection/reconcile"
|
||||
headers = {
|
||||
"Idempotency-Key": f"rmgr-freshness:v1:{slug}:{commit}",
|
||||
"X-StateHub-Source-Agent": "repo-manager",
|
||||
}
|
||||
try:
|
||||
resp = hub.post(path, json={"expected_commit": commit, "acknowledge_retirements": False},
|
||||
headers=headers, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except httpx.TimeoutException:
|
||||
return {"outcome": "failed", "reason": "hub_timeout_outcome_unknown",
|
||||
"http_status": None}
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
detail = _http_error_detail(exc)
|
||||
fields: dict[str, Any] = {"outcome": "failed", "http_status": status,
|
||||
"detail": {"hub_error": detail}}
|
||||
if status == 409 and isinstance(detail, dict) and "derived_commit" in detail:
|
||||
fields["reason"] = "forge_moved_during_run"
|
||||
fields["derived_commit"] = detail.get("derived_commit")
|
||||
elif status == 409 and isinstance(detail, dict) and "instance_role" in detail:
|
||||
fields["reason"] = "hub_not_primary"
|
||||
fields["abort"] = True
|
||||
fields["no_receipt"] = True
|
||||
elif status == 424:
|
||||
fields["reason"] = "forge_unreadable_by_hub"
|
||||
elif status == 502:
|
||||
fields["reason"] = "hub_derivation_failed"
|
||||
else:
|
||||
fields["reason"] = f"hub_http_{status}"
|
||||
return fields
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
return {"outcome": "failed", "reason": "hub_unavailable", "http_status": None,
|
||||
"detail": {"error": str(exc)[:300]}, "abort": True}
|
||||
|
||||
invalid = _verify_reconcile(body, identity, slug, commit)
|
||||
if invalid:
|
||||
# Kill criterion: never persist an unverified primary receipt as success.
|
||||
identity_fault = "primary" in invalid or "instance label" in invalid
|
||||
return {"outcome": "failed", "reason": f"invalid_receipt: {invalid}",
|
||||
"http_status": resp.status_code, "abort": True,
|
||||
"no_receipt": identity_fault,
|
||||
"derived_commit": body.get("derived_commit") if isinstance(body, dict) else None}
|
||||
outcome = body["outcome"]
|
||||
status = outcome["status"]
|
||||
fields = {
|
||||
"outcome": status,
|
||||
"reason": None,
|
||||
"http_status": resp.status_code,
|
||||
"expected_commit": body["expected_commit"],
|
||||
"derived_commit": body["derived_commit"],
|
||||
"projected_commit": outcome["commit"],
|
||||
"detail": {"counts": outcome.get("counts"), "notes": outcome.get("notes")},
|
||||
}
|
||||
if status == "refused":
|
||||
refused = outcome.get("refused") or []
|
||||
fields["reason"] = "hub_refused"
|
||||
fields["detail"]["refused"] = refused[:20]
|
||||
return fields
|
||||
|
||||
|
||||
def run(
|
||||
cfg: RunConfig,
|
||||
*,
|
||||
hub_transport: httpx.BaseTransport | None = None,
|
||||
forge_transport: httpx.BaseTransport | None = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> dict[str, Any]:
|
||||
if cfg.limit < 1:
|
||||
raise ValueError("--limit must be at least 1")
|
||||
if cfg.max_seconds <= 0:
|
||||
raise ValueError("--max-seconds must be positive")
|
||||
budget = _Budget(cfg.max_seconds, clock)
|
||||
started_at = utc_now_text()
|
||||
fire_id = cfg.fire_id or str(uuid.uuid4())
|
||||
revision = cfg.runner_revision or runner_revision()
|
||||
store = cfg.receipts_path or receipts_path()
|
||||
forge_headers = {"Authorization": f"token {cfg.forge_token}"} if cfg.forge_token else {}
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"schema": RUN_SCHEMA,
|
||||
"fire_id": fire_id,
|
||||
"dry_run": cfg.dry_run,
|
||||
"limit": cfg.limit,
|
||||
"max_seconds": cfg.max_seconds,
|
||||
"runner_revision": revision,
|
||||
"definition_id": cfg.definition_id,
|
||||
"definition_version": cfg.definition_version,
|
||||
"receipts_path": str(store),
|
||||
"started_at": started_at,
|
||||
}
|
||||
|
||||
with httpx.Client(base_url=cfg.api_base.rstrip("/"), follow_redirects=True,
|
||||
timeout=httpx.Timeout(30.0, connect=5.0),
|
||||
transport=hub_transport) as hub, \
|
||||
httpx.Client(base_url=cfg.forge_base.rstrip("/"), follow_redirects=True,
|
||||
timeout=httpx.Timeout(15.0, connect=5.0), headers=forge_headers,
|
||||
transport=forge_transport) as forge:
|
||||
health = hub.get("/state/health")
|
||||
health.raise_for_status()
|
||||
identity = _identity(health.json(), cfg)
|
||||
summary["hub"] = vars(identity)
|
||||
if not identity.verified_primary:
|
||||
raise HubNotPrimary(vars(identity))
|
||||
|
||||
repos = hub.get("/repos/", params={"limit": 1000})
|
||||
repos.raise_for_status()
|
||||
rows = {r["slug"]: r for r in repos.json()
|
||||
if isinstance(r, dict) and r.get("status") == "active" and r.get("slug")}
|
||||
active = sorted(rows)
|
||||
index = index_receipts(load_receipts(store))
|
||||
never = [s for s in active if s not in index.last]
|
||||
synced = _backing_synced(hub, never, cfg.snapshot_workers, budget)
|
||||
# A slow or failing snapshot falls back to the repository row's own
|
||||
# last_state_synced_at, which costs no extra request.
|
||||
fallback_used = 0
|
||||
for slug in never:
|
||||
if synced.get(slug) is None and rows[slug].get("last_state_synced_at"):
|
||||
synced[slug] = rows[slug]["last_state_synced_at"]
|
||||
fallback_used += 1
|
||||
ranked = rank_candidates(active, index, synced)
|
||||
selected = ranked[: cfg.limit]
|
||||
summary["active_repositories"] = len(active)
|
||||
summary["never_received"] = len(never)
|
||||
summary["rank_fallback_last_state_synced_at"] = fallback_used
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
deferred: list[str] = []
|
||||
aborted: str | None = None
|
||||
for position, cand in enumerate(selected):
|
||||
if aborted or budget.remaining() <= 0:
|
||||
deferred.extend(c.slug for c in selected[position:])
|
||||
break
|
||||
t0 = clock()
|
||||
attempt_started = utc_now_text()
|
||||
head = _forge_head(forge, cfg.forge_owner, cand.slug,
|
||||
timeout=min(15.0, max(budget.remaining(), 0.5)))
|
||||
row: dict[str, Any] = {
|
||||
"repo_slug": cand.slug,
|
||||
"rank_key": {"has_receipt": cand.has_receipt, "ts": cand.rank_ts},
|
||||
"forge_commit": head["commit"],
|
||||
"forge_ref": head["ref"],
|
||||
"last_success_commit": cand.last_success_commit,
|
||||
}
|
||||
fields: dict[str, Any]
|
||||
if head["commit"] is None:
|
||||
fields = {"outcome": "failed", "reason": "forge_head_unresolved",
|
||||
"http_status": None, "detail": {"forge_error": head["error"]}}
|
||||
row["action"] = "fail"
|
||||
elif head["commit"] == cand.last_success_commit:
|
||||
fields = {"outcome": "noop", "reason": "forge_head_matches_last_success",
|
||||
"http_status": None}
|
||||
row["action"] = "skip_noop"
|
||||
elif cfg.dry_run:
|
||||
row["action"] = "reconcile"
|
||||
results.append(row)
|
||||
continue
|
||||
elif budget.remaining() < MIN_RECONCILE_SECONDS:
|
||||
deferred.extend(c.slug for c in selected[position:])
|
||||
break
|
||||
else:
|
||||
row["action"] = "reconcile"
|
||||
fields = _reconcile(hub, identity, cand.slug, head["commit"],
|
||||
timeout=min(120.0, budget.remaining()))
|
||||
row.update({k: v for k, v in fields.items() if k not in {"abort", "no_receipt"}})
|
||||
if fields.get("abort"):
|
||||
aborted = fields["reason"]
|
||||
# Never persist a receipt whose primary identity the hub did not confirm.
|
||||
if fields.get("no_receipt"):
|
||||
row["receipt_withheld"] = True
|
||||
elif not cfg.dry_run:
|
||||
receipt = FreshnessReceipt(
|
||||
receipt_id=str(uuid.uuid4()),
|
||||
repo_slug=cand.slug,
|
||||
outcome=fields["outcome"],
|
||||
reason=fields.get("reason"),
|
||||
forge_commit=head["commit"],
|
||||
forge_ref=head["ref"],
|
||||
expected_commit=fields.get("expected_commit"),
|
||||
derived_commit=fields.get("derived_commit"),
|
||||
projected_commit=fields.get("projected_commit"),
|
||||
reconciled=row["action"] == "reconcile",
|
||||
http_status=fields.get("http_status"),
|
||||
hub=identity,
|
||||
runner_revision=revision,
|
||||
definition_id=cfg.definition_id,
|
||||
definition_version=cfg.definition_version,
|
||||
fire_id=fire_id,
|
||||
started_at=attempt_started,
|
||||
finished_at=utc_now_text(),
|
||||
elapsed_ms=round((clock() - t0) * 1000, 1),
|
||||
detail=fields.get("detail") or {},
|
||||
)
|
||||
append_receipt(receipt, store)
|
||||
row["receipt_id"] = receipt.receipt_id
|
||||
results.append(row)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for row in results:
|
||||
key = row.get("outcome") or f"plan:{row.get('action')}"
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
summary.update({
|
||||
"selected": [c.slug for c in selected],
|
||||
"results": results,
|
||||
"deferred": deferred,
|
||||
"aborted": aborted,
|
||||
"counts": counts,
|
||||
"finished_at": utc_now_text(),
|
||||
"elapsed_seconds": round(cfg.max_seconds - budget.remaining(), 2),
|
||||
})
|
||||
return summary
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
"""Git helpers via subprocess (ADR-001)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# The admitted receive scanner has a 90-second total budget. Leave bounded
|
||||
# headroom for transport and Git hooks; a timeout never proves remote refusal.
|
||||
PUSH_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
class GitError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _run(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and proc.returncode != 0:
|
||||
raise GitError(proc.stderr.strip() or proc.stdout.strip() or f"git {' '.join(args)} failed")
|
||||
return proc
|
||||
|
||||
|
||||
def head_sha(repo: Path) -> str | None:
|
||||
proc = _run(repo, "rev-parse", "HEAD", check=False)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout.strip() or None
|
||||
|
||||
|
||||
def is_git_repo(repo: Path) -> bool:
|
||||
return (repo / ".git").exists() or _run(repo, "rev-parse", "--git-dir", check=False).returncode == 0
|
||||
|
||||
|
||||
def commit_paths(
|
||||
repo: Path,
|
||||
paths: list[str],
|
||||
message: str,
|
||||
*,
|
||||
author_name: str = "repo-manager",
|
||||
author_email: str = "repo-manager@local",
|
||||
) -> str:
|
||||
"""Stage paths and commit. Returns new HEAD sha."""
|
||||
env_author = [
|
||||
"-c",
|
||||
f"user.name={author_name}",
|
||||
"-c",
|
||||
f"user.email={author_email}",
|
||||
]
|
||||
for path in paths:
|
||||
_run(repo, "add", "--", path)
|
||||
proc = subprocess.run(
|
||||
["git", *env_author, "commit", "-m", message],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise GitError(proc.stderr.strip() or proc.stdout.strip() or "git commit failed")
|
||||
sha = head_sha(repo)
|
||||
if not sha:
|
||||
raise GitError("commit succeeded but HEAD missing")
|
||||
return sha
|
||||
|
||||
|
||||
def push_ff(repo: Path) -> tuple[bool, str]:
|
||||
"""Best-effort push (push-seal compatible). Never force-pushes."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "push"],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=PUSH_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return True, (proc.stdout.strip() or "pushed")
|
||||
return False, (proc.stderr.strip() or proc.stdout.strip() or "push failed")
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "push timed out; remote outcome unverified; inspect the remote ref before retrying"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, str(exc)
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
"""Reviewed closed-source exceptions; enable only after receiving deployment.
|
||||
|
||||
No aliases, new IDs, binding replacement or source edits are authorized.
|
||||
Strict identity scans retain nonconformance findings for these legacy labels.
|
||||
"""
|
||||
from hashlib import sha256
|
||||
import json
|
||||
|
||||
BINDINGS = json.loads(r'''[
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/archived/260707-custodian-WP-0003-event-bridge.md",
|
||||
"sha256": "83ea8bcb151e003419b73c59dbd028f4a1ce3d1120a82e4a128a772503ff3b76",
|
||||
"record_id": "custodian-WP-0003",
|
||||
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
|
||||
"parent_record_id": "custodian-WP-0003"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0001-temporal-backbone.md",
|
||||
"sha256": "020e2eac973f2daa41c1687f8e867da9d521f945547e6aa82c87993b3ff6fc64",
|
||||
"record_id": "custodian-WP-0001",
|
||||
"uuid": "e22516cc-4fdf-4dad-a928-a9e04a9fa7c7",
|
||||
"parent_record_id": "custodian-WP-0001"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0002-triggers-ops.md",
|
||||
"sha256": "d8cbc61631c01b6f11a03a63f2fb6275fc853ff7f7459ce65973ed1a233daa01",
|
||||
"record_id": "custodian-WP-0002",
|
||||
"uuid": "3a4f47d9-8bc1-434e-acb4-bed5d4dacda0",
|
||||
"parent_record_id": "custodian-WP-0002"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0003a-event-bridge-model-rules-registry.md",
|
||||
"sha256": "ffa6682e9ba3aaf559bd44203d561c90dea69bcf1b1e778209a7b209e6e5cdd3",
|
||||
"record_id": "custodian-WP-0003a",
|
||||
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
|
||||
"parent_record_id": "custodian-WP-0003"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0003b-event-bridge-parser-workflow-triggers.md",
|
||||
"sha256": "4fc34e0f821e2df51e5c08c7ab34da35b65690381d01f0cfdf55d00c970b414c",
|
||||
"record_id": "custodian-WP-0003b",
|
||||
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
|
||||
"parent_record_id": "custodian-WP-0003"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0003c-event-bridge-context-integration.md",
|
||||
"sha256": "b6cd62b26515e509670ca39736b6bcec9633beedd46c9bd8f7598c06bac7276b",
|
||||
"record_id": "custodian-WP-0003c",
|
||||
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
|
||||
"parent_record_id": "custodian-WP-0003"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0004-railiance-ops.md",
|
||||
"sha256": "20df02eea740e109c937130e7f8801a636fc0990ed8c6d2ced55659f8957adb1",
|
||||
"record_id": "custodian-WP-0004",
|
||||
"uuid": "759b1255-aa78-42b5-8fab-c5fcf168f5f4",
|
||||
"parent_record_id": "custodian-WP-0004"
|
||||
},
|
||||
{
|
||||
"repo": "the-custodian",
|
||||
"path": "workplans/ADHOC-2026-08-25-retired.md",
|
||||
"sha256": "344a1293dbb3a67a944eb34ca4a0337487d8f047226027a2107922346fbab2dd",
|
||||
"record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED",
|
||||
"uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e",
|
||||
"parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md",
|
||||
"sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9",
|
||||
"record_id": "RAIL-BS-WP-0002",
|
||||
"uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d",
|
||||
"parent_record_id": "RAIL-BS-WP-0002",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "T01",
|
||||
"uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T02",
|
||||
"uuid": "e8510646-46ed-4697-a345-f3d3009eea78",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T03",
|
||||
"uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T04",
|
||||
"uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T05",
|
||||
"uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f",
|
||||
"status": "done"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md",
|
||||
"sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a",
|
||||
"record_id": "RAIL-BS-WP-0003",
|
||||
"uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b",
|
||||
"parent_record_id": "RAIL-BS-WP-0003",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "T01",
|
||||
"uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T02",
|
||||
"uuid": "ab166073-30a7-4702-a037-4091e8706e20",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T03",
|
||||
"uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T04",
|
||||
"uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d",
|
||||
"status": "done"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md",
|
||||
"sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3",
|
||||
"record_id": "RAIL-BS-WP-0004",
|
||||
"uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625",
|
||||
"parent_record_id": "RAIL-BS-WP-0004",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "T01",
|
||||
"uuid": "4526a842-ea31-4874-9231-92ab556cfe7b",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T02",
|
||||
"uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T03",
|
||||
"uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T04",
|
||||
"uuid": "08233868-d522-4117-bc4e-6c0f52545665",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T05",
|
||||
"uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T06",
|
||||
"uuid": "f8e4a094-c367-40eb-b895-da17bc144b07",
|
||||
"status": "done"
|
||||
}
|
||||
]
|
||||
}
|
||||
]''')
|
||||
|
||||
|
||||
def historical_source_paths(repo_root):
|
||||
entries = [entry for entry in BINDINGS if entry["repo"] == repo_root.name]
|
||||
paths = set()
|
||||
for entry in entries:
|
||||
path = repo_root / entry["path"]
|
||||
if path.is_symlink() or not path.is_file() or sha256(path.read_bytes()).hexdigest() != entry["sha256"]:
|
||||
raise ValueError("Historical source binding requires owner review: " + entry["path"])
|
||||
paths.add(entry["path"])
|
||||
return paths
|
||||
|
||||
|
||||
def historical_source_occurrences(repo_root):
|
||||
"""Exact frozen closed task occurrences; never a general local-ID alias."""
|
||||
from repo_manager.parse.workplan import parse_workplan_file
|
||||
from uuid import UUID
|
||||
paths=historical_source_paths(repo_root)
|
||||
occurrences=set(paths)
|
||||
for entry in BINDINGS:
|
||||
if entry['repo']!=repo_root.name or 'tasks' not in entry:continue
|
||||
parsed=parse_workplan_file(repo_root/entry['path'],repo_root=repo_root)
|
||||
actual=[{'label':task.id,'uuid':task.state_hub_task_id,'status':task.status} for task in parsed.tasks]
|
||||
if (parsed.id!=entry['record_id'] or parsed.state_hub_workstream_id!=entry['uuid']
|
||||
or parsed.status not in {'finished','archived','completed'} or actual!=entry['tasks']
|
||||
or any(task['status'] not in {'done','cancel'} for task in actual)):
|
||||
raise ValueError('Historical task binding requires owner review: '+entry['path'])
|
||||
UUID(entry['uuid'])
|
||||
for number,task in enumerate(actual,1):
|
||||
UUID(task['uuid'])
|
||||
occurrences.add(entry['path']+'#task-block-'+str(number))
|
||||
return occurrences
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"""Canonical State Hub access map for agent-facing AGENTS.md (CUST-WP-0067-T07).
|
||||
|
||||
The port map used to be prose copied into ~180 AGENTS.md files. Topology
|
||||
changes then required a fleet-wide rewrite. This module is the single source:
|
||||
edit ``config/state-hub-access.yaml`` and refresh with
|
||||
``rmgr scaffold --refresh-hub-access``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
BEGIN = "<!-- BEGIN STATE-HUB-ACCESS -->"
|
||||
END = "<!-- END STATE-HUB-ACCESS -->"
|
||||
DEFAULT_CONTRACT = Path(__file__).resolve().parents[2] / "config" / "state-hub-access.yaml"
|
||||
|
||||
_FALLBACK: dict[str, Any] = {
|
||||
"endpoints": [
|
||||
{"context": "Local workstation", "url": "http://127.0.0.1:8000"},
|
||||
{"context": "Remote (railiance01, in-cluster)", "url": "http://10.43.68.154:8000"},
|
||||
{"context": "Optional local edge relay", "url": "http://127.0.0.1:18080"},
|
||||
],
|
||||
"retired": [
|
||||
{
|
||||
"url": "http://127.0.0.1:18000",
|
||||
"reason": "reverse tunnel back to the workstation; the hub is in-cluster on railiance01",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
_TABLE_RE = re.compile(
|
||||
r"\| Context \| URL \|\r?\n\|[-:| ]+\|\r?\n(?:\|[^\n]*\|\r?\n)+",
|
||||
re.MULTILINE,
|
||||
)
|
||||
_MARKED_RE = re.compile(
|
||||
re.escape(BEGIN) + r".*?" + re.escape(END),
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def load_access(path: Path | None = None) -> dict[str, Any]:
|
||||
target = path or DEFAULT_CONTRACT
|
||||
if target.is_file():
|
||||
payload = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||
if isinstance(payload, dict) and payload.get("endpoints"):
|
||||
return payload
|
||||
return _FALLBACK
|
||||
|
||||
|
||||
def render_hub_access_block(access: dict[str, Any] | None = None) -> str:
|
||||
data = access or load_access()
|
||||
rows = ["| Context | URL |", "|---------|-----|"]
|
||||
for item in data.get("endpoints") or []:
|
||||
context = str(item.get("context") or "").strip()
|
||||
url = str(item.get("url") or "").strip()
|
||||
rows.append(f"| {context} | `{url}` |")
|
||||
retired = data.get("retired") or []
|
||||
note_lines: list[str] = []
|
||||
for item in retired:
|
||||
url = str(item.get("url") or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
note_lines.append(
|
||||
f"Do not instruct a remote agent to use `{url}`. That reverse tunnel "
|
||||
"is retired; on railiance01 reach the hub at the in-cluster address above."
|
||||
)
|
||||
body = "\n".join(rows)
|
||||
if note_lines:
|
||||
body = body + "\n\n" + "\n".join(note_lines)
|
||||
return f"{BEGIN}\n{body}\n{END}"
|
||||
|
||||
|
||||
def _table_is_hub_access(table: str) -> bool:
|
||||
lowered = table.lower()
|
||||
return "local workstation" in lowered and (
|
||||
"railiance01" in lowered or "10.43.68.154" in lowered or "127.0.0.1:18000" in lowered
|
||||
)
|
||||
|
||||
|
||||
def apply_hub_access_block(text: str, block: str | None = None) -> tuple[str, str]:
|
||||
"""Insert or replace the canonical access table. Returns (new_text, action)."""
|
||||
block = block or render_hub_access_block()
|
||||
if block in text:
|
||||
return text, "noop"
|
||||
marked = _MARKED_RE.search(text)
|
||||
if marked:
|
||||
return text[: marked.start()] + block + text[marked.end() :], "replaced-marked"
|
||||
for match in _TABLE_RE.finditer(text):
|
||||
if _table_is_hub_access(match.group(0)):
|
||||
return text[: match.start()] + block + "\n" + text[match.end() :], "replaced-table"
|
||||
heading = re.search(r"^## State Hub Integration\s*$", text, re.MULTILINE)
|
||||
if heading:
|
||||
insert_at = heading.end()
|
||||
return text[:insert_at] + "\n\n" + block + text[insert_at:], "inserted"
|
||||
if text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
return text + "\n## State Hub Integration\n\n" + block + "\n", "appended"
|
||||
|
||||
|
||||
def live_urls(access: dict[str, Any] | None = None) -> list[str]:
|
||||
data = access or load_access()
|
||||
return [str(item.get("url") or "").strip() for item in data.get("endpoints") or []]
|
||||
|
||||
|
||||
def retired_urls(access: dict[str, Any] | None = None) -> list[str]:
|
||||
data = access or load_access()
|
||||
return [str(item.get("url") or "").strip() for item in data.get("retired") or []]
|
||||
Loading…
Add table
Add a link
Reference in a new issue