feat: add fast forge work-record reconciliation
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
parent
a65cef02cf
commit
34f5cb3fc3
22 changed files with 799 additions and 162 deletions
18
AGENTS.md
18
AGENTS.md
|
|
@ -126,12 +126,14 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||
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: "<uuid>" # written by fix-consistency — do not edit
|
||||
state_hub_workstream_id: "<uuid>" # 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: "<uuid>" # written by fix-consistency — do not edit
|
||||
state_hub_task_id: "<uuid>" # 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.
|
||||
|
|
|
|||
80
Makefile
80
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; \
|
||||
|
|
|
|||
70
README.md
70
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`
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
151
api/routers/work_record_projection.py
Normal file
151
api/routers/work_record_projection.py
Normal file
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
api/schemas/work_record_projection.py
Normal file
38
api/schemas/work_record_projection.py
Normal file
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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=<slug>
|
||||
# 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 \
|
||||
|
|
|
|||
|
|
@ -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 <repo> --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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -114,19 +114,12 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||
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: "<uuid>" # fix-consistency — do not edit (legacy field name; workplan UUID)
|
||||
state_hub_workstream_id: "<uuid>" # 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: "<uuid>" # written by fix-consistency — do not edit
|
||||
state_hub_task_id: "<uuid>" # 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.
|
||||
|
|
|
|||
|
|
@ -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-<slug>.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**
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
148
tests/test_work_record_projection_api.py
Normal file
148
tests/test_work_record_projection_api.py
Normal file
|
|
@ -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() == []
|
||||
34
workplans/ADHOC-2026-08-30.md
Normal file
34
workplans/ADHOC-2026-08-30.md
Normal file
|
|
@ -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).
|
||||
69
workplans/STATE-WP-0086-fast-forge-projection-reconcile.md
Normal file
69
workplans/STATE-WP-0086-fast-forge-projection-reconcile.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue