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`.
|
decision / engagement). Do not park leftovers only in prose or `SCOPE.md`.
|
||||||
Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Residuals.
|
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)
|
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
|
```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
|
This assigns only missing deterministic identifiers, verifies the pushed
|
||||||
State Hub API is unavailable. This syncs task status from files into the hub DB.
|
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: ...
|
topic_slug: ...
|
||||||
created: "YYYY-MM-DD"
|
created: "YYYY-MM-DD"
|
||||||
updated: "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
|
id: STATE-WP-NNNN-T01
|
||||||
status: wait | todo | progress | done | cancel
|
status: wait | todo | progress | done | cancel
|
||||||
priority: high | medium | low
|
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.
|
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:
|
To create a new workplan:
|
||||||
1. Write the file following the format above
|
1. Write the file following the format above
|
||||||
2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or
|
2. Run `uv run --project ~/repo-manager rmgr sync --path . --push`.
|
||||||
State Hub API is unavailable.
|
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
|
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
|
|
@ -14,14 +14,23 @@ RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform
|
||||||
RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests
|
RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests
|
||||||
# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable.
|
# 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)
|
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:
|
start:
|
||||||
@echo "# run in different terminals"
|
@echo "# Normal production UI access (run in order)"
|
||||||
@echo "make db # docker compose up postgres"
|
@echo "make bridges # connect local :8000 to the railiance01 primary"
|
||||||
@echo "make api # start backend api"
|
@echo "make dashboard # verify the primary, then serve the UI on :3000"
|
||||||
@echo "make mcp-http # start state-hub mcp service"
|
@echo "make sync # push file-backed records and reconcile the exact forge commit"
|
||||||
@echo "make dashboard # Observable dev server on :3000"
|
@echo ""
|
||||||
@echo "make bridges # Set up ssh bridges for cross machines access"
|
@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:
|
install:
|
||||||
$(UV) sync
|
$(UV) sync
|
||||||
|
|
@ -63,12 +72,56 @@ mcp-http:
|
||||||
@fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true
|
@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
|
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
|
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
|
||||||
$(MAKE) dashboard-install
|
$(MAKE) dashboard-install
|
||||||
cd dashboard && npm run dev
|
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
|
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
|
# 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
|
## ops-bridge managed tunnels
|
||||||
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||||
tunnels-up:
|
tunnels-up: primary-port-clear
|
||||||
bridge up
|
bridge up
|
||||||
|
|
||||||
tunnels-status:
|
tunnels-status:
|
||||||
|
|
@ -149,7 +202,7 @@ tunnels-check:
|
||||||
|
|
||||||
## Ensure all ops-bridge tunnels are up and healthy.
|
## 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.
|
## 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..."
|
@echo "==> Bringing up all tunnels..."
|
||||||
bridge up
|
bridge up
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
@ -159,9 +212,10 @@ bridges:
|
||||||
@echo "==> Checking tunnel health..."
|
@echo "==> Checking tunnel health..."
|
||||||
bridge check
|
bridge check
|
||||||
|
|
||||||
## Start (or restart) the full backend — db + migrate + uvicorn.
|
## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn.
|
||||||
## Stops uvicorn on :8000 if already running, then starts fresh.
|
## This replaces anything on :8000, including the production State Hub tunnel.
|
||||||
api: db
|
api: db
|
||||||
|
@echo "WARNING: starting the local fallback API; this is not the railiance01 primary."
|
||||||
@echo "Waiting for postgres..."; \
|
@echo "Waiting for postgres..."; \
|
||||||
for i in 1 2 3 4 5 6 7 8 9 10; do \
|
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; \
|
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.
|
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
|
**Production:** the primary State Hub API runs in the railiance01-hosted k3s
|
||||||
port `8000` reaches it through the ops-bridge `state-hub-primary` tunnel. See
|
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,
|
[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access,
|
||||||
rollback, backups, and pragmatic limitations.
|
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`)
|
- Python 3.12+ with `uv` (`pip install uv`)
|
||||||
- Node.js 18+ (dashboard only)
|
- 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
|
```bash
|
||||||
cd /home/worsch/state-hub
|
cd /home/worsch/state-hub
|
||||||
|
|
@ -92,27 +97,51 @@ make install # uv sync
|
||||||
make db # docker compose up postgres
|
make db # docker compose up postgres
|
||||||
make migrate # alembic upgrade head
|
make migrate # alembic upgrade head
|
||||||
make seed # insert 6 canonical topics
|
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
|
```bash
|
||||||
make dashboard # installs dashboard deps if needed, then Observable dev server on :3000
|
make bridges # connect workstation :8000 to primary/railiance01
|
||||||
make dashboard-check # installs deps if needed, then runs Observable build
|
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
|
```bash
|
||||||
make db # docker compose up postgres
|
make mcp-http # optional local SSE adapter on :8001
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
### 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` | `uv sync` — install Python deps + entry points |
|
||||||
| `make install-cli` | Symlink `custodian` to `~/.local/bin` |
|
| `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 db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
|
||||||
| `make migrate` | `alembic upgrade head` |
|
| `make migrate` | `alembic upgrade head` |
|
||||||
| `make seed` | Insert 6 canonical topics (legacy bootstrap) |
|
| `make seed` | Insert 6 canonical topics (legacy bootstrap) |
|
||||||
| `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` |
|
| `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 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-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
|
||||||
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
|
| `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 dashboard` | Verify `primary/railiance01`, then start the Observable dashboard |
|
||||||
| `make check` | `curl /state/health` |
|
| `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 test` | Python test suite plus `make dashboard-check` |
|
||||||
| `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project |
|
| `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project |
|
||||||
| `make clean` | `docker compose down -v` (destroys DB volume) |
|
| `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
|
### 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`
|
2. Looks up the topic ID for the domain via `/topics/?status=active`
|
||||||
3. Checks that `state-hub` is in `~/.claude.json`
|
3. Checks that `state-hub` is in `~/.claude.json`
|
||||||
4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template`
|
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-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-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-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-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-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
|
||||||
| workplan | CUST-WP-0038 | backlog | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.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-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-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-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-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-T01 | done | — | workplans/ADHOC-2026-07-01.md |
|
||||||
| task | STATE-WP-ADHOC-2026-07-01-T02 | 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-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-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-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-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-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||||
| task | CUST-WP-0003-T03 | 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-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-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-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 review_contracts
|
||||||
from api.routers import identifier_migrations
|
from api.routers import identifier_migrations
|
||||||
from api.routers import repository_renames
|
from api.routers import repository_renames
|
||||||
|
from api.routers import work_record_projection
|
||||||
|
|
||||||
|
|
||||||
class ETagMiddleware(BaseHTTPMiddleware):
|
class ETagMiddleware(BaseHTTPMiddleware):
|
||||||
|
|
@ -112,6 +113,7 @@ app.include_router(consistency_sweep.router)
|
||||||
app.include_router(repos.router)
|
app.include_router(repos.router)
|
||||||
app.include_router(repository_renames.router)
|
app.include_router(repository_renames.router)
|
||||||
app.include_router(repository_renames.operation_router)
|
app.include_router(repository_renames.operation_router)
|
||||||
|
app.include_router(work_record_projection.router)
|
||||||
app.include_router(topics.router)
|
app.include_router(topics.router)
|
||||||
app.include_router(workstreams.router)
|
app.include_router(workstreams.router)
|
||||||
app.include_router(workstreams.workplan_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."""
|
"""Upsert workstation workplan file bindings for remote API index fallback."""
|
||||||
synced_at = datetime.now(timezone.utc)
|
synced_at = datetime.now(timezone.utc)
|
||||||
updated = 0
|
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:
|
for entry in body.bindings:
|
||||||
wp = await session.get(Workplan, entry.workplan_id)
|
wp = workplans.get(entry.workplan_id)
|
||||||
if wp is None:
|
if wp is None:
|
||||||
continue
|
continue
|
||||||
wp.backing_filename = entry.filename
|
wp.backing_filename = entry.filename
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class TaskStatusMixin(BaseModel):
|
||||||
|
|
||||||
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
||||||
id: uuid.UUID | None = None
|
id: uuid.UUID | None = None
|
||||||
|
record_id: str | None = None
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus = TaskStatus.todo
|
status: TaskStatus = TaskStatus.todo
|
||||||
|
|
@ -100,6 +101,7 @@ class TaskStatusBulkSync(BaseModel):
|
||||||
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
|
record_id: str | None = None
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus
|
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
|
title: str | None
|
||||||
status: str | None
|
status: str | None
|
||||||
priority: str | None
|
priority: str | None
|
||||||
|
description: str | None = None
|
||||||
|
needs_human: bool = False
|
||||||
|
intervention_note: str | None = None
|
||||||
|
blocking_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -135,6 +139,8 @@ class DerivedWorkplan:
|
||||||
status: str | None
|
status: str | None
|
||||||
relative_path: str
|
relative_path: str
|
||||||
archived: bool
|
archived: bool
|
||||||
|
owner: str | None = None
|
||||||
|
description: str | None = None
|
||||||
tasks: list[DerivedTask] = field(default_factory=list)
|
tasks: list[DerivedTask] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -180,6 +186,8 @@ class DerivedProjection:
|
||||||
"uuid": w.uuid,
|
"uuid": w.uuid,
|
||||||
"title": w.title,
|
"title": w.title,
|
||||||
"status": w.status,
|
"status": w.status,
|
||||||
|
"owner": w.owner,
|
||||||
|
"description": w.description,
|
||||||
"relative_path": w.relative_path,
|
"relative_path": w.relative_path,
|
||||||
"archived": w.archived,
|
"archived": w.archived,
|
||||||
"tasks": [
|
"tasks": [
|
||||||
|
|
@ -189,6 +197,10 @@ class DerivedProjection:
|
||||||
"title": t.title,
|
"title": t.title,
|
||||||
"status": t.status,
|
"status": t.status,
|
||||||
"priority": t.priority,
|
"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
|
for t in w.tasks
|
||||||
],
|
],
|
||||||
|
|
@ -279,6 +291,11 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
|
||||||
if not title:
|
if not title:
|
||||||
prev = [t for pos, t in headings if pos < m.start()]
|
prev = [t for pos, t in headings if pos < m.start()]
|
||||||
title = prev[-1] if prev else None
|
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(
|
out.append(
|
||||||
DerivedTask(
|
DerivedTask(
|
||||||
# A bare `T01` is not an identifier: it is unique only within
|
# 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,
|
title=title,
|
||||||
status=(str(block["status"]).strip() if block.get("status") else None),
|
status=(str(block["status"]).strip() if block.get("status") else None),
|
||||||
priority=(str(block["priority"]).strip() if block.get("priority") 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
|
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:
|
def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> DerivedProjection:
|
||||||
"""Derive a projection from an already-materialised checkout."""
|
"""Derive a projection from an already-materialised checkout."""
|
||||||
proj = DerivedProjection(repo_slug=repo_slug, commit=commit)
|
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),
|
status=(str(meta["status"]).strip() if meta.get("status") else None),
|
||||||
relative_path=str(path.relative_to(repo_root).as_posix()),
|
relative_path=str(path.relative_to(repo_root).as_posix()),
|
||||||
archived=path.parent.name == "archived",
|
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),
|
tasks=_parse_tasks(body, rid),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -696,6 +744,10 @@ def _sync_existing_workplan_tasks(
|
||||||
"workplan_id": row.id,
|
"workplan_id": row.id,
|
||||||
"record_id": dt.record_id,
|
"record_id": dt.record_id,
|
||||||
"title": (dt.title or 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)
|
st = _coerce_task_status(dt.status)
|
||||||
if st is not None:
|
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():
|
if dt.title and dt.title.strip() and ht.title != dt.title.strip():
|
||||||
ht.title = dt.title.strip()
|
ht.title = dt.title.strip()
|
||||||
changed = True
|
changed = True
|
||||||
|
if getattr(ht, "description", None) != dt.description:
|
||||||
|
ht.description = dt.description
|
||||||
|
changed = True
|
||||||
st = _coerce_task_status(dt.status)
|
st = _coerce_task_status(dt.status)
|
||||||
if st is not None and ht.status != st:
|
if st is not None and ht.status != st:
|
||||||
ht.status = st
|
ht.status = st
|
||||||
changed = True
|
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:
|
if changed:
|
||||||
outcome.updated_tasks.append(dt.record_id)
|
outcome.updated_tasks.append(dt.record_id)
|
||||||
|
|
||||||
|
|
@ -1054,7 +1126,9 @@ async def reset_repository_projection(
|
||||||
topic_id=repo.topic_id,
|
topic_id=repo.topic_id,
|
||||||
slug=w.record_id.lower(),
|
slug=w.record_id.lower(),
|
||||||
title=w.title or w.record_id,
|
title=w.title or w.record_id,
|
||||||
|
description=w.description,
|
||||||
status=w.status or "proposed",
|
status=w.status or "proposed",
|
||||||
|
owner=w.owner,
|
||||||
backing_filename=w.relative_path.rsplit("/", 1)[-1],
|
backing_filename=w.relative_path.rsplit("/", 1)[-1],
|
||||||
backing_relative_path=w.relative_path,
|
backing_relative_path=w.relative_path,
|
||||||
backing_archived=w.archived,
|
backing_archived=w.archived,
|
||||||
|
|
@ -1071,8 +1145,12 @@ async def reset_repository_projection(
|
||||||
workplan_id=row.id,
|
workplan_id=row.id,
|
||||||
record_id=t.record_id,
|
record_id=t.record_id,
|
||||||
title=t.title or t.record_id,
|
title=t.title or t.record_id,
|
||||||
|
description=t.description,
|
||||||
status=t.status or "todo",
|
status=t.status or "todo",
|
||||||
priority=t.priority or "medium",
|
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)
|
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():
|
if w.title and w.title.strip() and row.title != w.title.strip():
|
||||||
row.title = w.title.strip()
|
row.title = w.title.strip()
|
||||||
changed = True
|
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:
|
if w.status and row.status != w.status:
|
||||||
row.status = w.status
|
row.status = w.status
|
||||||
changed = True
|
changed = True
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,12 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
|
||||||
WriteRouteRule("POST", r"/decisions", "append", "record decision"),
|
WriteRouteRule("POST", r"/decisions", "append", "record decision"),
|
||||||
WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"),
|
WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"),
|
||||||
WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"),
|
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("PATCH", r"/decisions/[^/]+", "replace", "update decision"),
|
||||||
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
|
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
|
||||||
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
|
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:
|
File-backed workplans remain authoritative (ADR-001). After commits:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make fix-consistency REPO=<slug>
|
uv run --project ~/repo-manager rmgr sync --path /path/to/repo --push
|
||||||
# or from repo root:
|
|
||||||
make fix-consistency-here
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The 15-minute all-repo sweep is owned by activity-core on Railiance01. It
|
The command verifies `primary/railliance01`, then central derives and applies
|
||||||
reaches the API through the `actcore-state-hub-bridge` proxy chain. Manual
|
the exact pushed Forgejo commit. `statehub fix-consistency` remains the broad
|
||||||
invocation from the workstation still works:
|
deep-audit tool; it is not the interactive registration/update path.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://127.0.0.1:8000/consistency/sweep/remote-all \
|
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.
|
2. Run statehub outbox status on each host that may have queued writes.
|
||||||
3. Run statehub outbox replay until no due queued envelopes remain.
|
3. Run statehub outbox replay until no due queued envelopes remain.
|
||||||
4. Review conflict envelopes manually.
|
4. Review conflict envelopes manually.
|
||||||
5. Run `statehub fix-consistency` so file-backed workplan/task state
|
5. Run `rmgr sync --path <repo> --push` for repositories with file-backed
|
||||||
remains canonical after replay.
|
changes. Use `statehub fix-consistency` afterward only if a deep audit is
|
||||||
|
required.
|
||||||
6. Record a progress note with non-secret replay counts.
|
6. Record a progress note with non-secret replay counts.
|
||||||
|
|
|
||||||
|
|
@ -630,6 +630,24 @@ def _inject_task_id_frontmatter_list(
|
||||||
# API helpers
|
# 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(
|
def _api_get(
|
||||||
api_base: str,
|
api_base: str,
|
||||||
path: str,
|
path: str,
|
||||||
|
|
@ -646,15 +664,9 @@ def _api_get(
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
for attempt in range(_API_GET_RETRIES):
|
for attempt in range(_API_GET_RETRIES):
|
||||||
try:
|
try:
|
||||||
with _httpx.Client(
|
r = _api_client(api_base).get(path, params=filtered if filtered else None)
|
||||||
base_url=api_base,
|
r.raise_for_status()
|
||||||
timeout=10.0,
|
return r.json()
|
||||||
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()
|
|
||||||
except _httpx.HTTPStatusError as exc:
|
except _httpx.HTTPStatusError as exc:
|
||||||
if exc.response.status_code == 404:
|
if exc.response.status_code == 404:
|
||||||
return None
|
return None
|
||||||
|
|
@ -689,15 +701,9 @@ def _api_patch(api_base: str, path: str, body: dict) -> Any:
|
||||||
if not path.endswith("/"):
|
if not path.endswith("/"):
|
||||||
path += "/"
|
path += "/"
|
||||||
try:
|
try:
|
||||||
with _httpx.Client(
|
r = _api_client(api_base).patch(path, json=body)
|
||||||
base_url=api_base,
|
r.raise_for_status()
|
||||||
timeout=10.0,
|
return r.json()
|
||||||
follow_redirects=True,
|
|
||||||
headers=_legacy_meter_headers(),
|
|
||||||
) as c:
|
|
||||||
r = c.patch(path, json=body)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Return a sentinel dict so callers can distinguish "API error" from "success"
|
# Return a sentinel dict so callers can distinguish "API error" from "success"
|
||||||
# and report it rather than silently dropping the fix.
|
# 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("/"):
|
if not path.endswith("/"):
|
||||||
path += "/"
|
path += "/"
|
||||||
try:
|
try:
|
||||||
with _httpx.Client(
|
r = _api_client(api_base).put(path, json=body, timeout=30.0)
|
||||||
base_url=api_base,
|
r.raise_for_status()
|
||||||
timeout=30.0,
|
return r.json()
|
||||||
follow_redirects=True,
|
|
||||||
headers=_legacy_meter_headers(),
|
|
||||||
) as c:
|
|
||||||
r = c.put(path, json=body)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"_error": str(exc)}
|
return {"_error": str(exc)}
|
||||||
|
|
||||||
|
|
@ -729,15 +729,9 @@ def _api_post(api_base: str, path: str, body: dict) -> Any:
|
||||||
if not path.endswith("/"):
|
if not path.endswith("/"):
|
||||||
path += "/"
|
path += "/"
|
||||||
try:
|
try:
|
||||||
with _httpx.Client(
|
r = _api_client(api_base).post(path, json=body)
|
||||||
base_url=api_base,
|
r.raise_for_status()
|
||||||
timeout=10.0,
|
return r.json()
|
||||||
follow_redirects=True,
|
|
||||||
headers=_legacy_meter_headers(),
|
|
||||||
) as c:
|
|
||||||
r = c.post(path, json=body)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
except _httpx.HTTPStatusError as exc:
|
except _httpx.HTTPStatusError as exc:
|
||||||
detail = exc.response.text
|
detail = exc.response.text
|
||||||
if len(detail) > 500:
|
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"):
|
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
|
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
|
# Per-workplan checks
|
||||||
for wp_file, meta, body in workplan_infos:
|
for wp_file, meta, body in workplan_infos:
|
||||||
fname = workplan_display_path(repo_dir, wp_file)
|
fname = workplan_display_path(repo_dir, wp_file)
|
||||||
|
|
@ -1396,7 +1421,11 @@ def check_repo(
|
||||||
)
|
)
|
||||||
continue
|
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:
|
if ws is None:
|
||||||
wp_id = str(meta.get("id", "")).strip()
|
wp_id = str(meta.get("id", "")).strip()
|
||||||
if wp_id and (
|
if wp_id and (
|
||||||
|
|
@ -1461,7 +1490,16 @@ def check_repo(
|
||||||
# Continue to check drift even with mismatched repo
|
# Continue to check drift even with mismatched repo
|
||||||
|
|
||||||
tasks = get_tasks_from_workplan(meta, body)
|
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 = [
|
file_task_statuses = [
|
||||||
str(task.get("status", "")).strip()
|
str(task.get("status", "")).strip()
|
||||||
for task in tasks
|
for task in tasks
|
||||||
|
|
@ -1632,7 +1670,11 @@ def check_repo(
|
||||||
for t in db_tasks:
|
for t in db_tasks:
|
||||||
db_task_by_id[t["id"]] = t
|
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()
|
existing_dep_keys = set()
|
||||||
if isinstance(existing_deps, list):
|
if isinstance(existing_deps, list):
|
||||||
for dep in existing_deps:
|
for dep in existing_deps:
|
||||||
|
|
@ -1718,7 +1760,7 @@ def check_repo(
|
||||||
|
|
||||||
if t_sh_id:
|
if t_sh_id:
|
||||||
file_task_sh_ids.add(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 db_task is None:
|
||||||
if t_id and t_sh_id == _derived_work_record_uuid(t_id):
|
if t_id and t_sh_id == _derived_work_record_uuid(t_id):
|
||||||
report.add(
|
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"{check_id} skipped: this instance is not the identifier registrar "
|
||||||
f"({label}; do not retry or set STATEHUB_REGISTRAR directly; after "
|
f"({label}; do not retry or set STATEHUB_REGISTRAR directly; after "
|
||||||
"committing and pushing file-backed work run once: "
|
"committing and pushing file-backed work run once: "
|
||||||
"uv run --project ~/repo-manager rmgr registrar-reconcile "
|
"uv run --project ~/repo-manager rmgr sync --path . --push; "
|
||||||
"--path . --confirm-primary --push; ADR-007 interim / RMGR-WP-0005-T01)"
|
"ADR-012 / STATE-WP-0086)"
|
||||||
)
|
)
|
||||||
return True
|
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)
|
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:
|
||||||
```bash
|
```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
|
This assigns only missing deterministic identifiers, verifies the pushed
|
||||||
State Hub API is unavailable. This syncs task status from files into the hub DB.
|
Forgejo commit and `primary/railliance01`, then requests one central
|
||||||
If C-06/C-11 reports that this host is not the identifier registrar, do not
|
reconciliation. A queued receipt is pending evidence; rerun after
|
||||||
retry, export `STATEHUB_REGISTRAR`, or register records by hand. Commit and
|
connectivity returns. Use `statehub fix-consistency` for a separate deep audit.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -172,7 +165,7 @@ owner: codex
|
||||||
topic_slug: ...
|
topic_slug: ...
|
||||||
created: "YYYY-MM-DD"
|
created: "YYYY-MM-DD"
|
||||||
updated: "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
|
id: {WP_PREFIX}-NNNN-T01
|
||||||
status: wait | todo | progress | done | cancel
|
status: wait | todo | progress | done | cancel
|
||||||
priority: high | medium | low
|
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.
|
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:
|
To create a new workplan:
|
||||||
1. Write the file following the format above
|
1. Write the file following the format above
|
||||||
2. Run `statehub fix-consistency` locally.
|
2. Run `uv run --project ~/repo-manager rmgr sync --path . --push`.
|
||||||
3. On a non-registrar C-06/C-11 skip, use the repo-manager fallback documented
|
3. Run `statehub fix-consistency` only when a separate deep audit is needed.
|
||||||
above exactly once; never set registrar authority directly.
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
Propose 1–3 workplans — each a coherent strand, weeks to months, anchored to a
|
||||||
roadmap phase. **Wait for approval before creating.**
|
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
|
workplans/{WP_PREFIX}-NNNN-<slug>.md ← write this, commit it
|
||||||
```
|
```
|
||||||
Then register by running the consistency check — do **not** call
|
Then run the deterministic, forge-derived sync — do **not** call
|
||||||
`create_workplan`/`create_task` yourself; manual registration duplicates what
|
`create_workplan`/`create_task` yourself:
|
||||||
C-06 creates from the file:
|
|
||||||
```bash
|
```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`
|
Repo Manager writes deterministic `state_hub_workstream_id` and
|
||||||
(legacy frontmatter name — holds the workplan UUID) and `state_hub_task_id`
|
`state_hub_task_id` values, pushes the file, verifies `primary/railliance01`,
|
||||||
back into the file.
|
and asks central to derive that exact Forgejo commit. If connectivity is down,
|
||||||
|
the queued receipt is pending evidence; rerun the same command later.
|
||||||
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.
|
|
||||||
|
|
||||||
**Step 5 — Record the setup**
|
**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**
|
> State Hub is a *read model*. **Never register workplans or tasks by hand**
|
||||||
> (`create_workplan`, `create_task`) — write the workplan file in `workplans/`
|
> (`create_workplan`, `create_task`) — write the workplan file in `workplans/`
|
||||||
> and run `fix-consistency`; C-06 registers the workplan and tasks and writes
|
> and run `uv run --project ~/repo-manager rmgr sync --path . --push`.
|
||||||
> IDs back into the file. Manual registration creates duplicates when
|
> Repo Manager assigns missing deterministic IDs; central derives the exact
|
||||||
> fix-consistency runs. Work structure belongs in repo files (ADR-001).
|
> pushed Forgejo commit. Manual registration creates duplicate ownership.
|
||||||
> If C-06/C-11 is skipped on a non-registrar host, do not retry or set registrar
|
> Work structure belongs in repo files (ADR-001).
|
||||||
> 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.
|
|
||||||
>
|
>
|
||||||
> Legacy: `create_workstream` and `/workstreams/` remain as metered aliases —
|
> Legacy: `create_workstream` and `/workstreams/` remain as metered aliases —
|
||||||
> see `workplan-convention.md` (compatibility footnote).
|
> 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
|
a child workplan / decision / engagement). Do not leave actionable leftovers
|
||||||
only as prose or in `SCOPE.md`. See work-record-types § Residuals.
|
only as prose or in `SCOPE.md`. See work-record-types § Residuals.
|
||||||
3. Log progress (below).
|
3. Log progress (below).
|
||||||
4. `statehub fix-consistency` when workplan/queue files changed.
|
4. `uv run --project ~/repo-manager rmgr sync --path . --push` when workplan
|
||||||
A non-registrar C-06/C-11 skip uses the scoped repo-manager fallback above;
|
files changed. Use `statehub fix-consistency` separately for a deep audit.
|
||||||
repeated consistency runs cannot assign the missing UUIDs.
|
|
||||||
|
|
||||||
With MCP tools:
|
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:
|
then sync from the repo checkout:
|
||||||
```bash
|
```bash
|
||||||
git pull --ff-only
|
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),
|
The sync refuses uncommitted workplan files and a branch behind its upstream.
|
||||||
use the pull-before-fix mode from any shell with the State Hub CLI:
|
This prevents a workstation projection from getting ahead of the forge source.
|
||||||
```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.
|
|
||||||
|
|
|
||||||
|
|
@ -25,23 +25,17 @@ Promote anything requiring analysis, design, approval, dependencies, or multiple
|
||||||
planned phases into a normal workplan.
|
planned phases into a normal workplan.
|
||||||
|
|
||||||
Ecosystem todos from other agents arrive as `[repo:{REPO_SLUG}]` hub tasks —
|
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,
|
visible at session start. Pick one up by creating the workplan file, then run
|
||||||
and running `statehub fix-consistency` — C-06 registers the workplan in the hub.
|
the fast authoritative projection path:
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run --project ~/repo-manager rmgr registrar-reconcile \
|
uv run --project ~/repo-manager rmgr sync --path . --push
|
||||||
--path . --confirm-primary --push
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If it is unavailable, send one request to `repo-manager` naming the repository
|
Repo Manager assigns only missing deterministic identifiers. Central reads the
|
||||||
and missing canonical ids. Continue local work from files; hub UUID absence is
|
exact pushed Forgejo commit and updates its replaceable projection. Never
|
||||||
an indexing delay, not a reason to repeat the same checks.
|
register by hand with `create_workplan` or `create_task`. Use
|
||||||
|
`statehub fix-consistency` separately for a deep audit.
|
||||||
|
|
||||||
Task blocks use this shape:
|
Task blocks use this shape:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1648,7 +1648,7 @@ class TestC06WorkstreamCreation:
|
||||||
assert "state_hub_workstream_id" not in patched
|
assert "state_hub_workstream_id" not in patched
|
||||||
assert "state_hub_task_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("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 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)
|
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