From 34f5cb3fc3962bcc9e34419b9857dd98d79ba65c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 30 Aug 2026 22:38:54 +0200 Subject: [PATCH] feat: add fast forge work-record reconciliation Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c --- AGENTS.md | 18 ++- Makefile | 80 ++++++++-- README.md | 70 +++++--- WORK-RECORDS.md | 6 + api/main.py | 2 + api/routers/work_record_projection.py | 151 ++++++++++++++++++ api/routers/workstreams.py | 7 +- api/schemas/task.py | 2 + api/schemas/work_record_projection.py | 38 +++++ api/services/forge_projection.py | 84 ++++++++++ api/services/write_idempotency.py | 6 + docs/cluster-operating-model.md | 10 +- docs/offline-write-buffer.md | 5 +- scripts/consistency_check.py | 126 ++++++++++----- scripts/project_rules/agents-codex.template | 26 ++- scripts/project_rules/first-session.template | 28 +--- .../project_rules/session-protocol.template | 29 ++-- .../workplan-convention.template | 20 +-- tests/test_consistency_check.py | 2 +- tests/test_work_record_projection_api.py | 148 +++++++++++++++++ workplans/ADHOC-2026-08-30.md | 34 ++++ ...WP-0086-fast-forge-projection-reconcile.md | 69 ++++++++ 22 files changed, 799 insertions(+), 162 deletions(-) create mode 100644 api/routers/work_record_projection.py create mode 100644 api/schemas/work_record_projection.py create mode 100644 tests/test_work_record_projection_api.py create mode 100644 workplans/ADHOC-2026-08-30.md create mode 100644 workplans/STATE-WP-0086-fast-forge-projection-reconcile.md diff --git a/AGENTS.md b/AGENTS.md index 1cabb86..012e2ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,12 +126,14 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ decision / engagement). Do not park leftovers only in prose or `SCOPE.md`. Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Residuals. 3. Log: `POST /progress/` with a summary of what changed (name handoff ids) -4. After workplan file changes, run: +4. After workplan file changes, run the fast authoritative projection path: ```bash - statehub fix-consistency + uv run --project ~/repo-manager rmgr sync --path . --push ``` - Coding agents should run this directly; ask the operator only if the CLI or - State Hub API is unavailable. This syncs task status from files into the hub DB. + This assigns only missing deterministic identifiers, verifies the pushed + Forgejo commit, verifies `primary/railliance01`, and reconciles the repository + in one request. A queued result is pending evidence; retry after connectivity + returns. Run `statehub fix-consistency` separately when a deep audit is needed. --- @@ -225,7 +227,7 @@ owner: codex topic_slug: ... created: "YYYY-MM-DD" updated: "YYYY-MM-DD" -state_hub_workstream_id: "" # written by fix-consistency — do not edit +state_hub_workstream_id: "" # deterministic UUIDv5; managed by Repo Manager --- ``` @@ -242,7 +244,7 @@ derived health labels, not frontmatter statuses. id: STATE-WP-NNNN-T01 status: wait | todo | progress | done | cancel priority: high | medium | low -state_hub_task_id: "" # written by fix-consistency — do not edit +state_hub_task_id: "" # deterministic UUIDv5; managed by Repo Manager ` ` ` Task description text. @@ -257,5 +259,5 @@ not a kind. Fleet list lives on State Hub, not in `SCOPE.md`. To create a new workplan: 1. Write the file following the format above -2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or - State Hub API is unavailable. +2. Run `uv run --project ~/repo-manager rmgr sync --path . --push`. +3. Use `statehub fix-consistency` only for a separate deep consistency audit. diff --git a/Makefile b/Makefile index 33ceacd..9861491 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check 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 +.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 COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env PYTHON ?= python3 @@ -14,14 +14,23 @@ 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 "# run in different terminals" - @echo "make db # docker compose up postgres" - @echo "make api # start backend api" - @echo "make mcp-http # start state-hub mcp service" - @echo "make dashboard # Observable dev server on :3000" - @echo "make bridges # Set up ssh bridges for cross machines access" + @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 @@ -63,12 +72,56 @@ 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 -dashboard: +## 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 -check: +## 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 @@ -136,7 +189,7 @@ benchmark-summary-cache: ## ops-bridge managed tunnels ## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge -tunnels-up: +tunnels-up: primary-port-clear bridge up tunnels-status: @@ -149,7 +202,7 @@ tunnels-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: +bridges: primary-port-clear @echo "==> Bringing up all tunnels..." bridge up @echo "" @@ -159,9 +212,10 @@ bridges: @echo "==> Checking tunnel health..." bridge check -## Start (or restart) the full backend — db + migrate + uvicorn. -## Stops uvicorn on :8000 if already running, then starts fresh. +## 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; \ diff --git a/README.md b/README.md index 7292f98..9b96378 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,9 @@ then run consistency sync. All services bind to `127.0.0.1` only — nothing exposed to the network. -**Production:** the primary State Hub API runs on coulombcore-k3s. Workstation -port `8000` reaches it through the ops-bridge `state-hub-primary` tunnel. See +**Production:** the primary State Hub API runs in the railiance01-hosted k3s +cluster. Workstation port `8000` reaches it through the ops-bridge +`state-hub-primary` tunnel. See [`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access, rollback, backups, and pragmatic limitations. @@ -82,7 +83,11 @@ Repository rename operations use the phased, UUID-preserving workflow in - Python 3.12+ with `uv` (`pip install uv`) - Node.js 18+ (dashboard only) -### First-time +### First-time local development setup + +The local database and API are retained for development and disaster fallback. +They are not the normal production access path and conflict with the production +tunnel on port `8000`. ```bash cd /home/worsch/state-hub @@ -92,27 +97,51 @@ make install # uv sync make db # docker compose up postgres make migrate # alembic upgrade head make seed # insert 6 canonical topics -make api # db + migrate + uvicorn :8000 (restarts if running) +make api # LOCAL fallback: db + migrate + uvicorn :8000 ``` -### Dashboard +For a dashboard against that deliberate local backend, use +`make dashboard-local`. + +### Production dashboard (normal operation) + +The dashboard remains a workstation process, but its API is the primary State +Hub on railiance01. Start the tunnel first, then start the dashboard: ```bash -make dashboard # installs dashboard deps if needed, then Observable dev server on :3000 -make dashboard-check # installs deps if needed, then runs Observable build +make bridges # connect workstation :8000 to primary/railiance01 +make dashboard # verify the primary identity, then serve http://127.0.0.1:3000 ``` -### Start Everything +`make dashboard` refuses to start if port `8000` is unreachable or identifies +itself as anything other than `primary/railiance01`. This prevents the empty +local fallback database from being mistaken for production. -To start all the infrastructure on separate consoles do: +The MCP adapter is optional and is not required by the dashboard: ```bash -make db # docker compose up postgres -make mcp-http # start state-hub mcp service -make dashboard # Observable dev server on :3000 -make bridges # Set up ssh bridges for cross machines access +make mcp-http # optional local SSE adapter on :8001 ``` +### Fast work-record synchronization + +Workplan files remain authoritative. Repo Manager assigns missing UUIDv5 +identifiers locally, pushes the file commit, and asks the primary State Hub to +derive that exact commit from Forgejo in one transactional request: + +```bash +make bridges +make sync +``` + +The sync refuses an uncommitted or behind branch and will not write to a local +empty database: the API must identify itself as `primary/railliance01`. If the +primary is unavailable, Repo Manager writes an explicit pending receipt under +the checkout's local `.git/repo-manager/` state; rerunning `make sync` safely +replays the current pushed state. + +Use `statehub fix-consistency` only for the broader consistency/quality audit. +It is no longer the normal registration and task-update path. ### CLI @@ -130,17 +159,22 @@ custodian register-project # register cwd as a Custodian project |--------|-------------| | `make install` | `uv sync` — install Python deps + entry points | | `make install-cli` | Symlink `custodian` to `~/.local/bin` | -| `make db` | Start postgres container | +| `make start` | Print the production UI and local fallback startup paths | +| `make bridges` | Connect managed tunnels, including workstation `:8000` to the railiance01 primary | +| `make db` | Start the local fallback/development Postgres container | | `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) | | `make migrate` | `alembic upgrade head` | | `make seed` | Insert 6 canonical topics (legacy bootstrap) | | `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` | | `make register-from-classification-all` | Bulk reclassify all repos with classification files | -| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) | +| `make api` | Start the local fallback API; replaces the production tunnel on `:8000` | | `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` | | `make dashboard-check` | Build the Observable dashboard as a smoke/regression check | -| `make dashboard` | Install deps if needed, then start Observable dev server (restarts if running) | -| `make check` | `curl /state/health` | +| `make dashboard` | Verify `primary/railiance01`, then start the Observable dashboard | +| `make dashboard-local` | Start the dashboard against an intentional local/fallback API | +| `make sync` | Push missing deterministic IDs and reconcile this repo from the exact Forgejo commit | +| `make check` | Require a healthy `primary/railiance01` on workstation `:8000` | +| `make check-local` | Print `/state/health` without enforcing production identity | | `make test` | Python test suite plus `make dashboard-check` | | `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project | | `make clean` | `docker compose down -v` (destroys DB volume) | @@ -288,7 +322,7 @@ Prints API health, totals, and any blocking decisions. ### What `register-project` does -1. Verifies the API is reachable (fails fast with `make api` hint) +1. Verifies the API is reachable 2. Looks up the topic ID for the domain via `/topics/?status=active` 3. Checks that `state-hub` is in `~/.claude.json` 4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template` diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 492e9a6..3767020 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -12,6 +12,7 @@ | workplan | STATE-WP-ADHOC-2026-07-01 | finished | — | workplans/ADHOC-2026-07-01.md | | workplan | STATE-WP-ADHOC-2026-08-08 | finished | — | workplans/ADHOC-2026-08-08.md | | workplan | STATE-WP-ADHOC-2026-08-23 | finished | — | workplans/ADHOC-2026-08-23.md | +| workplan | STATE-WP-ADHOC-2026-08-30 | finished | — | workplans/ADHOC-2026-08-30.md | | workplan | CUST-WP-0003 | finished | — | workplans/CUST-WP-0003-whi-kpi-card.md | | workplan | CUST-WP-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md | | workplan | CUST-WP-0038 | backlog | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | @@ -56,6 +57,7 @@ | workplan | STATE-WP-0083 | active | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | | workplan | STATE-WP-0084 | active | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md | | workplan | STATE-WP-0085 | active | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| workplan | STATE-WP-0086 | active | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md | | task | STATE-WP-ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md | | task | STATE-WP-ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md | | task | STATE-WP-ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md | @@ -64,6 +66,7 @@ | task | STATE-WP-ADHOC-2026-08-08-T03 | done | — | workplans/ADHOC-2026-08-08.md | | task | STATE-WP-ADHOC-2026-08-08-T04 | done | — | workplans/ADHOC-2026-08-08.md | | task | STATE-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md | +| task | STATE-WP-ADHOC-2026-08-30-T01 | done | — | workplans/ADHOC-2026-08-30.md | | task | CUST-WP-0003-T01 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | | task | CUST-WP-0003-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | | task | CUST-WP-0003-T03 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | @@ -330,3 +333,6 @@ | task | STATE-WP-0085-T07 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T08 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T09 | progress | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0086-T01 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md | +| task | STATE-WP-0086-T02 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md | +| task | STATE-WP-0086-T03 | progress | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md | diff --git a/api/main.py b/api/main.py index 4364def..fecd89c 100644 --- a/api/main.py +++ b/api/main.py @@ -26,6 +26,7 @@ from api.routers import legacy_meter from api.routers import review_contracts from api.routers import identifier_migrations from api.routers import repository_renames +from api.routers import work_record_projection class ETagMiddleware(BaseHTTPMiddleware): @@ -112,6 +113,7 @@ app.include_router(consistency_sweep.router) app.include_router(repos.router) app.include_router(repository_renames.router) app.include_router(repository_renames.operation_router) +app.include_router(work_record_projection.router) app.include_router(topics.router) app.include_router(workstreams.router) app.include_router(workstreams.workplan_router) diff --git a/api/routers/work_record_projection.py b/api/routers/work_record_projection.py new file mode 100644 index 0000000..ae16d86 --- /dev/null +++ b/api/routers/work_record_projection.py @@ -0,0 +1,151 @@ +"""Primary-only fast path for one forge-derived repository projection.""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.config import settings +from api.database import get_session +from api.models.task import Task +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency +from api.schemas.work_record_projection import ( + RepositoryProjectionReconcile, + RepositoryProjectionSnapshot, +) +from api.services.forge_projection import ( + ForgeDeriveError, + ForgeUnreadableError, + derive_from_forge, + reset_repository_projection, +) +from api.services.repository_aliases import resolve_repository_slug + +router = APIRouter(prefix="/repos", tags=["repository-work-record-projection"]) + + +@router.get( + "/{slug}/work-record-projection/snapshot", + response_model=RepositoryProjectionSnapshot, +) +async def repository_work_record_snapshot( + slug: str, + session: AsyncSession = Depends(get_session), +) -> RepositoryProjectionSnapshot: + """Return all consistency-check inputs for one repository in one request.""" + resolution = await resolve_repository_slug(session, slug) + workplans = list( + ( + await session.execute( + select(Workplan) + .where(Workplan.repo_id == resolution.repo.id) + .order_by(Workplan.slug) + ) + ).scalars() + ) + workplan_ids = [workplan.id for workplan in workplans] + tasks: list[Task] = [] + dependencies: list[WorkplanDependency] = [] + if workplan_ids: + tasks = list( + ( + await session.execute( + select(Task) + .where(Task.workplan_id.in_(workplan_ids)) + .order_by(Task.workplan_id, Task.id) + ) + ).scalars() + ) + dependencies = list( + ( + await session.execute( + select(WorkplanDependency) + .where(WorkplanDependency.from_workplan_id.in_(workplan_ids)) + .order_by( + WorkplanDependency.from_workplan_id, WorkplanDependency.id + ) + ) + ).scalars() + ) + return RepositoryProjectionSnapshot( + schema="state-hub.repository-projection-snapshot.v1", + repo_slug=resolution.canonical_slug, + repo_id=resolution.repo.id, + workplans=workplans, + tasks=tasks, + dependencies=dependencies, + ) + + +@router.post("/{slug}/work-record-projection/reconcile") +async def reconcile_repository_work_records( + slug: str, + body: RepositoryProjectionReconcile, + session: AsyncSession = Depends(get_session), +) -> dict: + """Derive an exact pushed commit centrally and apply it transactionally.""" + if settings.state_hub_instance_role != "primary": + raise HTTPException( + status_code=409, + detail={ + "message": "repository projection writes require the primary State Hub", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + }, + ) + + try: + derived = await asyncio.to_thread(derive_from_forge, slug) + except ForgeUnreadableError as exc: + raise HTTPException( + status_code=424, + detail={ + "message": "repository is unreadable from the forge", + "detail": str(exc)[:300], + }, + ) from exc + except ForgeDeriveError as exc: + raise HTTPException( + status_code=502, + detail={ + "message": "forge projection derivation failed", + "detail": str(exc)[:300], + }, + ) from exc + + if derived.commit.lower() != body.expected_commit: + raise HTTPException( + status_code=409, + detail={ + "message": "forge default branch is not at the expected commit", + "expected_commit": body.expected_commit, + "derived_commit": derived.commit, + }, + ) + + outcome = await reset_repository_projection( + session, + slug, + acknowledge_retirements=body.acknowledge_retirements, + derived=derived, + ) + if outcome.status in {"applied", "noop"} or outcome.released: + await session.commit() + else: + await session.rollback() + + from api.routers.workstreams import _invalidate_workplan_index_cache + + _invalidate_workplan_index_cache() + return { + "schema": "state-hub.repository-projection-reconcile.v1", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + "expected_commit": body.expected_commit, + "derived_commit": derived.commit, + "outcome": outcome.to_dict(), + } diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index 5ee52c4..bca8e47 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -411,8 +411,11 @@ async def sync_workplan_bindings( """Upsert workstation workplan file bindings for remote API index fallback.""" synced_at = datetime.now(timezone.utc) updated = 0 + requested_ids = {entry.workplan_id for entry in body.bindings} + rows = await session.execute(select(Workplan).where(Workplan.id.in_(requested_ids))) + workplans = {workplan.id: workplan for workplan in rows.scalars().all()} for entry in body.bindings: - wp = await session.get(Workplan, entry.workplan_id) + wp = workplans.get(entry.workplan_id) if wp is None: continue wp.backing_filename = entry.filename @@ -524,4 +527,4 @@ async def archive_workplan( workplan_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Workplan: - return await _archive_workplan(workplan_id=workplan_id, session=session) \ No newline at end of file + return await _archive_workplan(workplan_id=workplan_id, session=session) diff --git a/api/schemas/task.py b/api/schemas/task.py index 2e4526d..0c353f4 100644 --- a/api/schemas/task.py +++ b/api/schemas/task.py @@ -20,6 +20,7 @@ class TaskStatusMixin(BaseModel): class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin): id: uuid.UUID | None = None + record_id: str | None = None title: str description: str | None = None status: TaskStatus = TaskStatus.todo @@ -100,6 +101,7 @@ class TaskStatusBulkSync(BaseModel): class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin): model_config = ConfigDict(from_attributes=True) id: uuid.UUID + record_id: str | None = None title: str description: str | None = None status: TaskStatus diff --git a/api/schemas/work_record_projection.py b/api/schemas/work_record_projection.py new file mode 100644 index 0000000..7167cbf --- /dev/null +++ b/api/schemas/work_record_projection.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import re +import uuid +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + +from api.schemas.task import TaskRead +from api.schemas.workplan import WorkplanRead +from api.schemas.workplan_dependency import WorkplanDependencyRead + + +class RepositoryProjectionReconcile(BaseModel): + expected_commit: str + acknowledge_retirements: bool = False + + @field_validator("expected_commit") + @classmethod + def validate_commit(cls, value: str) -> str: + value = value.strip().lower() + if not re.fullmatch(r"[0-9a-f]{40}", value): + raise ValueError("expected_commit must be a full 40-character Git SHA") + return value + + +class RepositoryProjectionSnapshot(BaseModel): + """One bounded read of a repository's complete work-record projection.""" + + schema_version: Literal["state-hub.repository-projection-snapshot.v1"] = Field( + validation_alias="schema", + serialization_alias="schema", + ) + repo_slug: str + repo_id: uuid.UUID + workplans: list[WorkplanRead] + tasks: list[TaskRead] + dependencies: list[WorkplanDependencyRead] diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index 5600330..939bc56 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -125,6 +125,10 @@ class DerivedTask: title: str | None status: str | None priority: str | None + description: str | None = None + needs_human: bool = False + intervention_note: str | None = None + blocking_reason: str | None = None @dataclass @@ -135,6 +139,8 @@ class DerivedWorkplan: status: str | None relative_path: str archived: bool + owner: str | None = None + description: str | None = None tasks: list[DerivedTask] = field(default_factory=list) @@ -180,6 +186,8 @@ class DerivedProjection: "uuid": w.uuid, "title": w.title, "status": w.status, + "owner": w.owner, + "description": w.description, "relative_path": w.relative_path, "archived": w.archived, "tasks": [ @@ -189,6 +197,10 @@ class DerivedProjection: "title": t.title, "status": t.status, "priority": t.priority, + "description": t.description, + "needs_human": t.needs_human, + "intervention_note": t.intervention_note, + "blocking_reason": t.blocking_reason, } for t in w.tasks ], @@ -279,6 +291,11 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: if not title: prev = [t for pos, t in headings if pos < m.start()] title = prev[-1] if prev else None + following_headings = [pos for pos, _title in headings if pos > m.end()] + description_end = min(following_headings) if following_headings else len(body) + description = str(block.get("description") or "").strip() + if not description: + description = body[m.end() : description_end].strip() out.append( DerivedTask( # A bare `T01` is not an identifier: it is unique only within @@ -298,11 +315,36 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: title=title, status=(str(block["status"]).strip() if block.get("status") else None), priority=(str(block["priority"]).strip() if block.get("priority") else None), + description=description or None, + needs_human=bool(block.get("needs_human", False)), + intervention_note=( + str(block["intervention_note"]).strip() + if block.get("intervention_note") + else None + ), + blocking_reason=( + str(block["blocking_reason"]).strip() + if block.get("blocking_reason") + else None + ), ) ) return out +def _workplan_description(body: str) -> str | None: + """Return bounded prose under ``## Goal`` when one is present.""" + match = re.search(r"^##\s+Goal\s*$", body, re.MULTILINE | re.IGNORECASE) + if match is None: + return None + remainder = body[match.end() :] + next_heading = re.search(r"^##\s+", remainder, re.MULTILINE) + if next_heading is not None: + remainder = remainder[: next_heading.start()] + value = remainder.strip() + return value[:4000] or None + + def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> DerivedProjection: """Derive a projection from an already-materialised checkout.""" proj = DerivedProjection(repo_slug=repo_slug, commit=commit) @@ -331,6 +373,12 @@ def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> Derive status=(str(meta["status"]).strip() if meta.get("status") else None), relative_path=str(path.relative_to(repo_root).as_posix()), archived=path.parent.name == "archived", + owner=(str(meta["owner"]).strip() if meta.get("owner") else None), + description=( + str(meta["description"]).strip() + if meta.get("description") + else _workplan_description(body) + ), tasks=_parse_tasks(body, rid), ) ) @@ -696,6 +744,10 @@ def _sync_existing_workplan_tasks( "workplan_id": row.id, "record_id": dt.record_id, "title": (dt.title or dt.record_id), + "description": dt.description, + "needs_human": dt.needs_human, + "intervention_note": dt.intervention_note, + "blocking_reason": dt.blocking_reason, } st = _coerce_task_status(dt.status) if st is not None: @@ -715,10 +767,30 @@ def _sync_existing_workplan_tasks( if dt.title and dt.title.strip() and ht.title != dt.title.strip(): ht.title = dt.title.strip() changed = True + if getattr(ht, "description", None) != dt.description: + ht.description = dt.description + changed = True st = _coerce_task_status(dt.status) if st is not None and ht.status != st: ht.status = st changed = True + if getattr(ht, "needs_human", False) != dt.needs_human: + ht.needs_human = dt.needs_human + changed = True + if getattr(ht, "intervention_note", None) != dt.intervention_note: + ht.intervention_note = dt.intervention_note + changed = True + if getattr(ht, "blocking_reason", None) != dt.blocking_reason: + ht.blocking_reason = dt.blocking_reason + changed = True + if dt.priority: + try: + task_priority = TaskPriority(dt.priority.strip().lower()) + except ValueError: + task_priority = None + if task_priority is not None and getattr(ht, "priority", None) != task_priority: + ht.priority = task_priority + changed = True if changed: outcome.updated_tasks.append(dt.record_id) @@ -1054,7 +1126,9 @@ async def reset_repository_projection( topic_id=repo.topic_id, slug=w.record_id.lower(), title=w.title or w.record_id, + description=w.description, status=w.status or "proposed", + owner=w.owner, backing_filename=w.relative_path.rsplit("/", 1)[-1], backing_relative_path=w.relative_path, backing_archived=w.archived, @@ -1071,8 +1145,12 @@ async def reset_repository_projection( workplan_id=row.id, record_id=t.record_id, title=t.title or t.record_id, + description=t.description, status=t.status or "todo", priority=t.priority or "medium", + needs_human=t.needs_human, + intervention_note=t.intervention_note, + blocking_reason=t.blocking_reason, ) ) outcome.created.append(w.record_id) @@ -1090,6 +1168,12 @@ async def reset_repository_projection( if w.title and w.title.strip() and row.title != w.title.strip(): row.title = w.title.strip() changed = True + if w.description and getattr(row, "description", None) != w.description: + row.description = w.description + changed = True + if getattr(row, "owner", None) != w.owner: + row.owner = w.owner + changed = True if w.status and row.status != w.status: row.status = w.status changed = True diff --git a/api/services/write_idempotency.py b/api/services/write_idempotency.py index a7c06b5..7d8c543 100644 --- a/api/services/write_idempotency.py +++ b/api/services/write_idempotency.py @@ -41,6 +41,12 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = ( WriteRouteRule("POST", r"/decisions", "append", "record decision"), WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"), WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"), + WriteRouteRule( + "POST", + r"/repos/[^/]+/work-record-projection/reconcile", + "replace", + "reconcile one forge-derived repository projection", + ), WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"), WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"), WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"), diff --git a/docs/cluster-operating-model.md b/docs/cluster-operating-model.md index 8147da0..cb0962d 100644 --- a/docs/cluster-operating-model.md +++ b/docs/cluster-operating-model.md @@ -138,14 +138,12 @@ Cutover sequence reference: `CUST-WP-0011-T07` (2026-07-03). File-backed workplans remain authoritative (ADR-001). After commits: ```bash -make fix-consistency REPO= -# or from repo root: -make fix-consistency-here +uv run --project ~/repo-manager rmgr sync --path /path/to/repo --push ``` -The 15-minute all-repo sweep is owned by activity-core on Railiance01. It -reaches the API through the `actcore-state-hub-bridge` proxy chain. Manual -invocation from the workstation still works: +The command verifies `primary/railliance01`, then central derives and applies +the exact pushed Forgejo commit. `statehub fix-consistency` remains the broad +deep-audit tool; it is not the interactive registration/update path. ```bash curl -s -X POST http://127.0.0.1:8000/consistency/sweep/remote-all \ diff --git a/docs/offline-write-buffer.md b/docs/offline-write-buffer.md index fd8fe14..44b8265 100644 --- a/docs/offline-write-buffer.md +++ b/docs/offline-write-buffer.md @@ -109,6 +109,7 @@ remain queueable or rejected per the write allowlist above. 2. Run statehub outbox status on each host that may have queued writes. 3. Run statehub outbox replay until no due queued envelopes remain. 4. Review conflict envelopes manually. -5. Run `statehub fix-consistency` so file-backed workplan/task state - remains canonical after replay. +5. Run `rmgr sync --path --push` for repositories with file-backed + changes. Use `statehub fix-consistency` afterward only if a deep audit is + required. 6. Record a progress note with non-secret replay counts. diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 3ffb92d..77524f3 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -630,6 +630,24 @@ def _inject_task_id_frontmatter_list( # API helpers # --------------------------------------------------------------------------- +_API_CLIENTS: dict[tuple[str, object], Any] = {} + + +def _api_client(api_base: str) -> Any: + """Reuse one keep-alive pool per API/client implementation for this run.""" + key = (api_base.rstrip("/"), _httpx.Client) + client = _API_CLIENTS.get(key) + if client is None: + client = _httpx.Client( + base_url=api_base, + timeout=10.0, + follow_redirects=True, + headers=_legacy_meter_headers(), + ) + _API_CLIENTS[key] = client + return client + + def _api_get( api_base: str, path: str, @@ -646,15 +664,9 @@ def _api_get( last_error: Exception | None = None for attempt in range(_API_GET_RETRIES): try: - with _httpx.Client( - base_url=api_base, - timeout=10.0, - follow_redirects=True, - headers=_legacy_meter_headers(), - ) as c: - r = c.get(path, params=filtered if filtered else None) - r.raise_for_status() - return r.json() + r = _api_client(api_base).get(path, params=filtered if filtered else None) + r.raise_for_status() + return r.json() except _httpx.HTTPStatusError as exc: if exc.response.status_code == 404: return None @@ -689,15 +701,9 @@ def _api_patch(api_base: str, path: str, body: dict) -> Any: if not path.endswith("/"): path += "/" try: - with _httpx.Client( - base_url=api_base, - timeout=10.0, - follow_redirects=True, - headers=_legacy_meter_headers(), - ) as c: - r = c.patch(path, json=body) - r.raise_for_status() - return r.json() + r = _api_client(api_base).patch(path, json=body) + r.raise_for_status() + return r.json() except Exception as exc: # Return a sentinel dict so callers can distinguish "API error" from "success" # and report it rather than silently dropping the fix. @@ -710,15 +716,9 @@ def _api_put(api_base: str, path: str, body: dict) -> Any: if not path.endswith("/"): path += "/" try: - with _httpx.Client( - base_url=api_base, - timeout=30.0, - follow_redirects=True, - headers=_legacy_meter_headers(), - ) as c: - r = c.put(path, json=body) - r.raise_for_status() - return r.json() + r = _api_client(api_base).put(path, json=body, timeout=30.0) + r.raise_for_status() + return r.json() except Exception as exc: return {"_error": str(exc)} @@ -729,15 +729,9 @@ def _api_post(api_base: str, path: str, body: dict) -> Any: if not path.endswith("/"): path += "/" try: - with _httpx.Client( - base_url=api_base, - timeout=10.0, - follow_redirects=True, - headers=_legacy_meter_headers(), - ) as c: - r = c.post(path, json=body) - r.raise_for_status() - return r.json() + r = _api_client(api_base).post(path, json=body) + r.raise_for_status() + return r.json() except _httpx.HTTPStatusError as exc: detail = exc.response.text if len(detail) > 500: @@ -1357,6 +1351,37 @@ def check_repo( if task_file_id and task_sh_id and task_sh_id not in ("~", "null", "None", "none"): task_file_id_to_sh_id[task_file_id] = task_sh_id + # Modern hubs expose the entire repo-scoped read model in one response. + # Keep the older per-record calls as a rolling-deploy compatibility path. + projection_snapshot = _api_get( + api_base, + f"/repos/{repo_slug}/work-record-projection/snapshot", + return_error=True, + ) + snapshot_available = ( + isinstance(projection_snapshot, dict) + and projection_snapshot.get("schema") + == "state-hub.repository-projection-snapshot.v1" + and str(projection_snapshot.get("repo_id")) == repo_id + ) + snapshot_workplans_by_id: dict[str, dict] = {} + snapshot_tasks_by_workplan: dict[str, list[dict]] = {} + snapshot_dependencies_by_workplan: dict[str, list[dict]] = {} + if snapshot_available: + for row in projection_snapshot.get("workplans", []): + if isinstance(row, dict) and row.get("id"): + snapshot_workplans_by_id[str(row["id"])] = row + for row in projection_snapshot.get("tasks", []): + if isinstance(row, dict) and row.get("workplan_id"): + snapshot_tasks_by_workplan.setdefault( + str(row["workplan_id"]), [] + ).append(row) + for row in projection_snapshot.get("dependencies", []): + if isinstance(row, dict) and row.get("from_workplan_id"): + snapshot_dependencies_by_workplan.setdefault( + str(row["from_workplan_id"]), [] + ).append(row) + # Per-workplan checks for wp_file, meta, body in workplan_infos: fname = workplan_display_path(repo_dir, wp_file) @@ -1396,7 +1421,11 @@ def check_repo( ) continue - ws = _api_get(api_base, f"/workplans/{ws_id}") + ws = ( + snapshot_workplans_by_id.get(ws_id) + if snapshot_available + else _api_get(api_base, f"/workplans/{ws_id}") + ) if ws is None: wp_id = str(meta.get("id", "")).strip() if wp_id and ( @@ -1461,7 +1490,16 @@ def check_repo( # Continue to check drift even with mismatched repo tasks = get_tasks_from_workplan(meta, body) - db_tasks = _api_get(api_base, "/tasks", {"workplan_id": ws_id}) + db_tasks = ( + snapshot_tasks_by_workplan.get(ws_id, []) + if snapshot_available + else _api_get(api_base, "/tasks", {"workplan_id": ws_id}) + ) + db_tasks_by_id = { + str(row.get("id")): row + for row in db_tasks + if isinstance(row, dict) and row.get("id") + } if isinstance(db_tasks, list) else {} file_task_statuses = [ str(task.get("status", "")).strip() for task in tasks @@ -1632,7 +1670,11 @@ def check_repo( for t in db_tasks: db_task_by_id[t["id"]] = t - existing_deps = _api_get(api_base, f"/workplans/{ws_id}/dependencies") or [] + existing_deps = ( + snapshot_dependencies_by_workplan.get(ws_id, []) + if snapshot_available + else _api_get(api_base, f"/workplans/{ws_id}/dependencies") or [] + ) existing_dep_keys = set() if isinstance(existing_deps, list): for dep in existing_deps: @@ -1718,7 +1760,7 @@ def check_repo( if t_sh_id: file_task_sh_ids.add(t_sh_id) - db_task = _api_get(api_base, f"/tasks/{t_sh_id}") + db_task = db_tasks_by_id.get(t_sh_id) if db_task is None: if t_id and t_sh_id == _derived_work_record_uuid(t_id): report.add( @@ -2818,8 +2860,8 @@ def _skip_non_registrar_mint(report: "ConsistencyReport", check_id: str, label: f"{check_id} skipped: this instance is not the identifier registrar " f"({label}; do not retry or set STATEHUB_REGISTRAR directly; after " "committing and pushing file-backed work run once: " - "uv run --project ~/repo-manager rmgr registrar-reconcile " - "--path . --confirm-primary --push; ADR-007 interim / RMGR-WP-0005-T01)" + "uv run --project ~/repo-manager rmgr sync --path . --push; " + "ADR-012 / STATE-WP-0086)" ) return True diff --git a/scripts/project_rules/agents-codex.template b/scripts/project_rules/agents-codex.template index 9118037..e160205 100644 --- a/scripts/project_rules/agents-codex.template +++ b/scripts/project_rules/agents-codex.template @@ -114,19 +114,12 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ 3. Log: `POST /progress/` with a summary of what changed (name handoff ids) 4. After workplan file changes, run: ```bash - statehub fix-consistency + uv run --project ~/repo-manager rmgr sync --path . --push ``` - Coding agents should run this directly; ask the operator only if the CLI or - State Hub API is unavailable. This syncs task status from files into the hub DB. - If C-06/C-11 reports that this host is not the identifier registrar, do not - retry, export `STATEHUB_REGISTRAR`, or register records by hand. Commit and - push the file-backed work first, then run the repo-manager fallback once: - ```bash - uv run --project ~/repo-manager rmgr registrar-reconcile \ - --path . --confirm-primary --push - ``` - If unavailable, send one deduplicated registrar request to `repo-manager` - naming the repo and canonical ids; UUID absence does not block local work. + This assigns only missing deterministic identifiers, verifies the pushed + Forgejo commit and `primary/railliance01`, then requests one central + reconciliation. A queued receipt is pending evidence; rerun after + connectivity returns. Use `statehub fix-consistency` for a separate deep audit. --- @@ -172,7 +165,7 @@ owner: codex topic_slug: ... created: "YYYY-MM-DD" updated: "YYYY-MM-DD" -state_hub_workstream_id: "" # fix-consistency — do not edit (legacy field name; workplan UUID) +state_hub_workstream_id: "" # deterministic UUIDv5; managed by Repo Manager --- ``` @@ -193,7 +186,7 @@ API/MCP/frontmatter bridges until `STATE-WP-0069` retires them — see id: {WP_PREFIX}-NNNN-T01 status: wait | todo | progress | done | cancel priority: high | medium | low -state_hub_task_id: "" # written by fix-consistency — do not edit +state_hub_task_id: "" # deterministic UUIDv5; managed by Repo Manager ` ` ` Task description text. @@ -208,6 +201,5 @@ not a kind. Fleet list lives on State Hub, not in `SCOPE.md`. To create a new workplan: 1. Write the file following the format above -2. Run `statehub fix-consistency` locally. -3. On a non-registrar C-06/C-11 skip, use the repo-manager fallback documented - above exactly once; never set registrar authority directly. +2. Run `uv run --project ~/repo-manager rmgr sync --path . --push`. +3. Run `statehub fix-consistency` only when a separate deep audit is needed. diff --git a/scripts/project_rules/first-session.template b/scripts/project_rules/first-session.template index 50d3df0..1bdb179 100644 --- a/scripts/project_rules/first-session.template +++ b/scripts/project_rules/first-session.template @@ -15,31 +15,19 @@ Look for TODOs, open branches, half-finished files. Note done vs. started but in Propose 1–3 workplans — each a coherent strand, weeks to months, anchored to a roadmap phase. **Wait for approval before creating.** -**Step 4 — Write the workplan file; fix-consistency registers it (ADR-001)** +**Step 4 — Write the workplan file; Repo Manager projects it (ADR-001)** ``` workplans/{WP_PREFIX}-NNNN-.md ← write this, commit it ``` -Then register by running the consistency check — do **not** call -`create_workplan`/`create_task` yourself; manual registration duplicates what -C-06 creates from the file: +Then run the deterministic, forge-derived sync — do **not** call +`create_workplan`/`create_task` yourself: ```bash -statehub fix-consistency --repo {REPO_SLUG} +uv run --project ~/repo-manager rmgr sync --path . --push ``` -C-06 creates the hub workplan + tasks and writes `state_hub_workstream_id` -(legacy frontmatter name — holds the workplan UUID) and `state_hub_task_id` -back into the file. - -If C-06/C-11 is skipped because the host is not the identifier registrar, -commit and push the new workplan, then invoke the governed fallback once: - -```bash -uv run --project ~/repo-manager rmgr registrar-reconcile \ - --path . --confirm-primary --push -``` - -Never export `STATEHUB_REGISTRAR` or create the hub records manually. If the -fallback is unavailable, send one request to `repo-manager` and keep working -from the authoritative files. +Repo Manager writes deterministic `state_hub_workstream_id` and +`state_hub_task_id` values, pushes the file, verifies `primary/railliance01`, +and asks central to derive that exact Forgejo commit. If connectivity is down, +the queued receipt is pending evidence; rerun the same command later. **Step 5 — Record the setup** ``` diff --git a/scripts/project_rules/session-protocol.template b/scripts/project_rules/session-protocol.template index 777ecf5..06ff8ab 100644 --- a/scripts/project_rules/session-protocol.template +++ b/scripts/project_rules/session-protocol.template @@ -58,14 +58,10 @@ If no workplans: follow First Session Protocol (`first-session.md`). > State Hub is a *read model*. **Never register workplans or tasks by hand** > (`create_workplan`, `create_task`) — write the workplan file in `workplans/` -> and run `fix-consistency`; C-06 registers the workplan and tasks and writes -> IDs back into the file. Manual registration creates duplicates when -> fix-consistency runs. Work structure belongs in repo files (ADR-001). -> If C-06/C-11 is skipped on a non-registrar host, do not retry or set registrar -> authority directly. Commit and push the file changes, then run once: -> `uv run --project ~/repo-manager rmgr registrar-reconcile --path . -> --confirm-primary --push`. If unavailable, send one deduplicated request to -> `repo-manager` and continue from the files. +> and run `uv run --project ~/repo-manager rmgr sync --path . --push`. +> Repo Manager assigns missing deterministic IDs; central derives the exact +> pushed Forgejo commit. Manual registration creates duplicate ownership. +> Work structure belongs in repo files (ADR-001). > > Legacy: `create_workstream` and `/workstreams/` remain as metered aliases — > see `workplan-convention.md` (compatibility footnote). @@ -77,9 +73,8 @@ If no workplans: follow First Session Protocol (`first-session.md`). a child workplan / decision / engagement). Do not leave actionable leftovers only as prose or in `SCOPE.md`. See work-record-types § Residuals. 3. Log progress (below). -4. `statehub fix-consistency` when workplan/queue files changed. - A non-registrar C-06/C-11 skip uses the scoped repo-manager fallback above; - repeated consistency runs cannot assign the missing UUIDs. +4. `uv run --project ~/repo-manager rmgr sync --path . --push` when workplan + files changed. Use `statehub fix-consistency` separately for a deep audit. With MCP tools: ``` @@ -95,13 +90,7 @@ If workplan files were modified, ensure the local copy is up to date first, then sync from the repo checkout: ```bash git pull --ff-only -statehub fix-consistency +uv run --project ~/repo-manager rmgr sync --path . --push ``` -For repos where implementation runs on a remote machine (e.g. CoulombCore), -use the pull-before-fix mode from any shell with the State Hub CLI: -```bash -statehub fix-consistency --repo {REPO_SLUG} --remote -``` -**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback -will sync the file to match DB. **C-16** (repo behind remote) blocks all writes -until you pull — intentional to prevent clobbering remote progress. +The sync refuses uncommitted workplan files and a branch behind its upstream. +This prevents a workstation projection from getting ahead of the forge source. diff --git a/scripts/project_rules/workplan-convention.template b/scripts/project_rules/workplan-convention.template index 2e4ed96..81b73be 100644 --- a/scripts/project_rules/workplan-convention.template +++ b/scripts/project_rules/workplan-convention.template @@ -25,23 +25,17 @@ Promote anything requiring analysis, design, approval, dependencies, or multiple planned phases into a normal workplan. Ecosystem todos from other agents arrive as `[repo:{REPO_SLUG}]` hub tasks — -visible at session start. Pick one up by creating the workplan file, committing, -and running `statehub fix-consistency` — C-06 registers the workplan in the hub. -Never register by hand with `create_workplan` (legacy MCP alias: `create_workstream`). - -If `fix-consistency` reports C-06/C-11 skipped because this host is not the -identifier registrar, further retries cannot help. Do not set -`STATEHUB_REGISTRAR` and do not create hub rows manually. Commit and push the -file-backed work, then run the scoped repo-manager fallback once: +visible at session start. Pick one up by creating the workplan file, then run +the fast authoritative projection path: ```bash -uv run --project ~/repo-manager rmgr registrar-reconcile \ - --path . --confirm-primary --push +uv run --project ~/repo-manager rmgr sync --path . --push ``` -If it is unavailable, send one request to `repo-manager` naming the repository -and missing canonical ids. Continue local work from files; hub UUID absence is -an indexing delay, not a reason to repeat the same checks. +Repo Manager assigns only missing deterministic identifiers. Central reads the +exact pushed Forgejo commit and updates its replaceable projection. Never +register by hand with `create_workplan` or `create_task`. Use +`statehub fix-consistency` separately for a deep audit. Task blocks use this shape: diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index a58f6f0..363249e 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -1648,7 +1648,7 @@ class TestC06WorkstreamCreation: assert "state_hub_workstream_id" not in patched assert "state_hub_task_id" not in patched assert any("not the identifier registrar" in fix for fix in report.fixes_applied) - assert any("rmgr registrar-reconcile" in fix for fix in report.fixes_applied) + assert any("rmgr sync" in fix for fix in report.fixes_applied) assert not any("set STATEHUB_REGISTRAR=1" in fix for fix in report.fixes_applied) assert any(issue.check_id == "C-06" for issue in report.issues) diff --git a/tests/test_work_record_projection_api.py b/tests/test_work_record_projection_api.py new file mode 100644 index 0000000..bcbb5ff --- /dev/null +++ b/tests/test_work_record_projection_api.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from api.services import forge_projection as fp +from tests.conftest import create_test_domain, create_test_repo + + +def _derived(commit: str) -> fp.DerivedProjection: + workplan_id = "TEST-WP-ADHOC-2026-08-30" + task_id = f"{workplan_id}-T01" + return fp.DerivedProjection( + repo_slug="projection-test", + commit=commit, + workplans=[ + fp.DerivedWorkplan( + record_id=workplan_id, + uuid=fp.derived_record_uuid(workplan_id), + title="Ad Hoc — 2026-08-30", + status="active", + relative_path="workplans/ADHOC-2026-08-30.md", + archived=False, + owner="codex", + description="Small opportunistic fixes.", + tasks=[ + fp.DerivedTask( + record_id=task_id, + uuid=fp.derived_record_uuid(task_id), + title="Repair registration", + status="progress", + priority="high", + description="Make registration deterministic.", + needs_human=True, + intervention_note="Review the rollout.", + blocking_reason="Waiting for rollout approval.", + ) + ], + ) + ], + ) + + +async def test_primary_reconciles_exact_commit_and_replays(client, monkeypatch) -> None: + await create_test_domain(client) + repo = await create_test_repo(client, slug="projection-test") + commit = "a" * 40 + calls = 0 + + def derive(_slug: str): + nonlocal calls + calls += 1 + return _derived(commit) + + from api.config import settings + from api.routers import work_record_projection as route + + monkeypatch.setattr(settings, "state_hub_instance_role", "primary") + monkeypatch.setattr(settings, "state_hub_instance_label", "railliance01") + monkeypatch.setattr(route, "derive_from_forge", derive) + headers = {"Idempotency-Key": f"projection-test:{commit}"} + response = await client.post( + "/repos/projection-test/work-record-projection/reconcile", + json={"expected_commit": commit}, + headers=headers, + ) + assert response.status_code == 200, response.text + assert response.json()["outcome"]["status"] == "applied" + + replay = await client.post( + "/repos/projection-test/work-record-projection/reconcile", + json={"expected_commit": commit}, + headers=headers, + ) + assert replay.status_code == 200 + assert replay.headers["X-StateHub-Idempotency-Replay"] == "true" + assert calls == 1 + + workplan_id = fp.derived_record_uuid("TEST-WP-ADHOC-2026-08-30") + workplan = await client.get(f"/workplans/{workplan_id}") + assert workplan.status_code == 200 + assert workplan.json()["repo_id"] == repo["id"] + assert workplan.json()["owner"] == "codex" + assert workplan.json()["description"] == "Small opportunistic fixes." + + tasks = await client.get("/tasks/", params={"workplan_id": workplan_id}) + assert tasks.status_code == 200 + task = tasks.json()[0] + assert task["record_id"] == "TEST-WP-ADHOC-2026-08-30-T01" + assert task["status"] == "progress" + assert task["priority"] == "high" + assert task["needs_human"] is True + assert task["intervention_note"] == "Review the rollout." + + snapshot = await client.get( + "/repos/projection-test/work-record-projection/snapshot" + ) + assert snapshot.status_code == 200, snapshot.text + payload = snapshot.json() + assert payload["schema"] == "state-hub.repository-projection-snapshot.v1" + assert payload["repo_id"] == repo["id"] + assert [row["id"] for row in payload["workplans"]] == [str(workplan_id)] + assert [row["record_id"] for row in payload["tasks"]] == [ + "TEST-WP-ADHOC-2026-08-30-T01" + ] + assert payload["dependencies"] == [] + + +async def test_reconcile_rejects_non_primary_before_forge_access( + client, monkeypatch +) -> None: + from api.config import settings + from api.routers import work_record_projection as route + + monkeypatch.setattr(settings, "state_hub_instance_role", "cache") + monkeypatch.setattr(settings, "state_hub_instance_label", "workstation") + + def forbidden(_slug: str): + raise AssertionError("forge must not be read by a non-primary endpoint") + + monkeypatch.setattr(route, "derive_from_forge", forbidden) + response = await client.post( + "/repos/projection-test/work-record-projection/reconcile", + json={"expected_commit": "a" * 40}, + ) + assert response.status_code == 409 + assert response.json()["detail"]["instance_role"] == "cache" + + +async def test_reconcile_rejects_commit_mismatch_without_db_changes( + client, monkeypatch +) -> None: + await create_test_domain(client) + await create_test_repo(client, slug="projection-test") + from api.config import settings + from api.routers import work_record_projection as route + + monkeypatch.setattr(settings, "state_hub_instance_role", "primary") + monkeypatch.setattr(settings, "state_hub_instance_label", "railliance01") + monkeypatch.setattr(route, "derive_from_forge", lambda _slug: _derived("b" * 40)) + response = await client.post( + "/repos/projection-test/work-record-projection/reconcile", + json={"expected_commit": "a" * 40}, + ) + assert response.status_code == 409 + rows = await client.get( + "/workplans/", + params={"repo_id": (await client.get("/repos/projection-test")).json()["id"]}, + ) + assert rows.status_code == 200 + assert rows.json() == [] diff --git a/workplans/ADHOC-2026-08-30.md b/workplans/ADHOC-2026-08-30.md new file mode 100644 index 0000000..ed0f869 --- /dev/null +++ b/workplans/ADHOC-2026-08-30.md @@ -0,0 +1,34 @@ +--- +id: STATE-WP-ADHOC-2026-08-30 +type: workplan +title: "Ad hoc fixes — 2026-08-30" +domain: infotech +repo: state-hub +status: finished +owner: codex +topic_slug: state-hub +created: "2026-08-30" +updated: "2026-08-30" +state_hub_workstream_id: "0b6218b2-8dce-5c11-aa5a-196c0521f3fe" +--- + +## Align UI startup with the production State Hub + +```task +id: STATE-WP-ADHOC-2026-08-30-T01 +status: done +priority: high +state_hub_task_id: "82e36947-3ca3-522d-b5ae-78c2e00e16d8" +``` + +Make the railiance01 production tunnel plus workstation dashboard the normal +documented startup path. Clearly separate local fallback commands and prevent +the normal dashboard/tunnel targets from silently using an empty local API. + +Completed 2026-08-30. The README and Makefile now present `make bridges` +followed by `make dashboard` as the normal production UI path. The bridge guard +rejects a non-primary listener on port 8000; the dashboard and `make check` +require `primary/railiance01`; `make dashboard-local` remains available for +intentional fallback development. `make dashboard-check` built all 71 pages +successfully (with the pre-existing `/docs/intakes` → `/suggestions` broken-link +warning). diff --git a/workplans/STATE-WP-0086-fast-forge-projection-reconcile.md b/workplans/STATE-WP-0086-fast-forge-projection-reconcile.md new file mode 100644 index 0000000..f9eb4cb --- /dev/null +++ b/workplans/STATE-WP-0086-fast-forge-projection-reconcile.md @@ -0,0 +1,69 @@ +--- +id: STATE-WP-0086 +type: workplan +title: "Fast single-repository forge projection reconciliation" +domain: infotech +repo: state-hub +status: active +owner: codex +topic_slug: infotech +created: "2026-08-30" +updated: "2026-08-30" +related: + - STATE-WP-0083 + - RMGR-WP-0005 + - RMGR-WP-0012 +state_hub_workstream_id: "c43b1b2c-2f84-540a-9b93-03a13ed148f1" +--- + +# Fast single-repository forge projection reconciliation + +## Goal + +Expose the existing forge-derived repository reset as a guarded, idempotent, +single-request compatibility API. The central hub reads the exact pushed forge +commit itself and applies workplan/task changes in one database transaction. + +## Guarded exact-commit API + +```task +id: STATE-WP-0086-T01 +status: done +priority: high +state_hub_task_id: "1b72b4e2-419a-5b55-91a7-3f56afba7480" +``` + +Add a primary-only endpoint that derives one repository from Forgejo, compares +the derived commit with the caller's expected commit, and commits or rolls back +the complete repository reconciliation atomically. + +## Complete task and workplan field projection + +```task +id: STATE-WP-0086-T02 +status: done +priority: high +state_hub_task_id: "25e1a146-47b2-5704-af45-b314c5a5a353" +``` + +Carry owner, priority, description, blocking, and human-intervention fields +through the existing forge derivation and update path without weakening its +collision, unreadable-source, or retirement safeguards. + +## Idempotency, regression, and performance proof + +```task +id: STATE-WP-0086-T03 +status: progress +priority: high +state_hub_task_id: "88723069-59d4-5b7e-85a4-36c3575382af" +``` + +Test wrong-instance rejection, commit mismatch, replay, transactional rollback, +ad-hoc creation, task updates, and bounded request count. Document the endpoint +as the fast path while retaining the full consistency checker for deep audits. + +Focused projection tests are green, including idempotent replay and exact-commit +refusal, and the complete State Hub suite is the implementation gate. This task +remains in progress until the endpoint is deployed and a live primary receipt +records the production latency and counts.