feat(repos): multi-machine path support via host_paths

Adds a JSONB column `host_paths` to managed_repos mapping
hostname → absolute local path. Fixes the consistency-checker
failure when the same repo lives at different paths on different
machines (e.g. /home/worsch/marki-docx on the workstation vs
/home/tegwick/marki-docx on custodiancore).

Changes:
- Migration g4b5c6d7e8f9: adds host_paths JSONB (default {})
- Model: host_paths Mapped[dict] column
- Schemas: host_paths in RepoRead; new RepoPathRegister schema
- Router: POST /repos/{slug}/paths/ — merges one host entry
- consistency_check.py: resolve_repo_path() prefers host_paths
  [hostname] over local_path; --repo-path CLI override added
- MCP: update_repo_path(slug, path, host?) tool
- Makefile: register-path target; REPO_PATH passthrough on
  check-consistency and fix-consistency targets
- TOOLS.md: documents update_repo_path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 16:30:55 +01:00
parent 77ea5bd16f
commit 2c8561be70
8 changed files with 133 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: install install-cli db db-tools migrate seed api dashboard check start clean register-project validate-adr add-domain rename-domain add-repo list-repos cleanup-stale tunnel tunnel-daemon tunnel-loop tunnel-status tunnel-stop install-hooks install-hooks-all gitea-inventory .PHONY: install install-cli db db-tools migrate seed api dashboard check start clean register-project validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnel tunnel-daemon tunnel-loop tunnel-status tunnel-stop install-hooks install-hooks-all gitea-inventory
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
@@ -135,6 +135,14 @@ rename-domain:
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool -d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool
## Register this machine's local path for a repo: make register-path REPO=marki-docx PATH=/home/tegwick/marki-docx
register-path:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
@test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths/" \
-H "Content-Type: application/json" \
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool
## List repos for a domain: make list-repos DOMAIN=railiance ## List repos for a domain: make list-repos DOMAIN=railiance
list-repos: list-repos:
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
@@ -166,15 +174,19 @@ validate-adr:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1) @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1)
uv run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",) uv run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",)
## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian ## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override]
check-consistency: check-consistency:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO=<slug>"; exit 1) @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO=<slug>"; exit 1)
uv run python scripts/consistency_check.py --repo "$(REPO)" $(if $(API_BASE),--api-base "$(API_BASE)",) uv run python scripts/consistency_check.py --repo "$(REPO)" \
$(if $(API_BASE),--api-base "$(API_BASE)",) \
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",)
## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian ## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override]
fix-consistency: fix-consistency:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO=<slug>"; exit 1) @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO=<slug>"; exit 1)
uv run python scripts/consistency_check.py --repo "$(REPO)" --fix $(if $(API_BASE),--api-base "$(API_BASE)",) uv run python scripts/consistency_check.py --repo "$(REPO)" --fix \
$(if $(API_BASE),--api-base "$(API_BASE)",) \
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",)
## Check all registered repos for ADR-001 consistency ## Check all registered repos for ADR-001 consistency
check-consistency-all: check-consistency-all:

View File

@@ -2,7 +2,7 @@ import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid from api.models.base import Base, TimestampMixin, new_uuid
@@ -20,6 +20,7 @@ class ManagedRepo(Base, TimestampMixin):
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False)
local_path: Mapped[str | None] = mapped_column(Text, nullable=True) local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
host_paths: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True) remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")

View File

@@ -15,6 +15,7 @@ from api.schemas.managed_repo import (
DispatchWorkstream, DispatchWorkstream,
RepoCreate, RepoCreate,
RepoDispatch, RepoDispatch,
RepoPathRegister,
RepoRead, RepoRead,
RepoUpdate, RepoUpdate,
) )
@@ -89,6 +90,26 @@ async def update_repo(
return repo return repo
@router.post("/{slug}/paths/", response_model=RepoRead)
async def register_host_path(
slug: str,
body: RepoPathRegister,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
"""Register or update the local path for a specific host.
Merges {"host": path} into host_paths without overwriting other entries.
Use this when a repo lives at a different absolute path on different machines.
"""
repo = await _get_repo_by_slug(slug, session)
updated = dict(repo.host_paths or {})
updated[body.host] = body.path
repo.host_paths = updated
await session.commit()
await session.refresh(repo)
return repo
@router.patch("/{slug}/archive", response_model=RepoRead) @router.patch("/{slug}/archive", response_model=RepoRead)
async def archive_repo( async def archive_repo(
slug: str, slug: str,

View File

@@ -24,6 +24,12 @@ class RepoUpdate(BaseModel):
last_state_synced_at: datetime | None = None last_state_synced_at: datetime | None = None
class RepoPathRegister(BaseModel):
"""Register a machine-local path for a repo on a specific host."""
host: str
path: str
class RepoRead(BaseModel): class RepoRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
@@ -32,6 +38,7 @@ class RepoRead(BaseModel):
slug: str slug: str
name: str name: str
local_path: str | None = None local_path: str | None = None
host_paths: dict = {}
remote_url: str | None = None remote_url: str | None = None
description: str | None = None description: str | None = None
status: str status: str

View File

@@ -104,6 +104,7 @@ Domains are now first-class DB entities. Use `list_domains()` to discover availa
| `archive_domain(slug)` | `slug` | Soft-delete; fails if active topics exist. | | `archive_domain(slug)` | `slug` | Soft-delete; fails if active topics exist. |
| `list_domain_repos(domain_slug)` | `domain_slug` | List repos registered under a domain. | | `list_domain_repos(domain_slug)` | `domain_slug` | List repos registered under a domain. |
| `register_repo(domain_slug, name, ...)` | `slug?`; `local_path?`; `remote_url?` | Register a git repo under a domain. | | `register_repo(domain_slug, name, ...)` | `slug?`; `local_path?`; `remote_url?` | Register a git repo under a domain. |
| `update_repo_path(repo_slug, path, host?)` | `repo_slug`: e.g. `"marki-docx"`; `path`: absolute local path; `host`: defaults to current hostname | Register this machine's local path for a repo. Use when the same repo lives at different paths on different machines (e.g. `/home/worsch/…` vs `/home/tegwick/…`). The consistency checker prefers this over `local_path`. |
--- ---

View File

@@ -971,6 +971,27 @@ def register_repo(
return json.dumps(repo, indent=2) return json.dumps(repo, indent=2)
@mcp.tool()
def update_repo_path(repo_slug: str, path: str, host: str | None = None) -> str:
"""Register or update the local filesystem path for a repo on a specific host.
Use this when a repo lives at a different absolute path on different machines
(e.g. /home/worsch/marki-docx on the workstation vs /home/tegwick/marki-docx
on custodiancore). The consistency checker will prefer the host-specific path
over the legacy local_path field.
Args:
repo_slug: Managed-repo slug (e.g. 'marki-docx')
path: Absolute local path on the target machine (e.g. '/home/tegwick/marki-docx')
host: Hostname to register the path for. Defaults to the current machine's hostname.
"""
import socket as _socket
if not host:
host = _socket.gethostname()
repo = _post(f"/repos/{repo_slug}/paths", {"host": host, "path": path})
return json.dumps(repo, indent=2)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ADR-001 compliance validation # ADR-001 compliance validation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -0,0 +1,38 @@
"""add host_paths to managed_repos
Revision ID: g4b5c6d7e8f9
Revises: f3a4b5c6d7e8
Create Date: 2026-03-16
Adds a JSONB column `host_paths` to managed_repos mapping hostname → absolute
local path. This allows the same repo to be registered at different paths on
different machines (e.g. /home/worsch/marki-docx on the workstation and
/home/tegwick/marki-docx on custodiancore). The consistency checker and any
other path-resolving code prefers host_paths[current_hostname] over local_path.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "g4b5c6d7e8f9"
down_revision = "f3a4b5c6d7e8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"managed_repos",
sa.Column(
"host_paths",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
)
def downgrade() -> None:
op.drop_column("managed_repos", "host_paths")

View File

@@ -33,6 +33,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import re import re
import socket
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -294,7 +295,22 @@ def _api_post(api_base: str, path: str, body: dict) -> Any:
# Core check engine # Core check engine
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def check_repo(api_base: str, repo_slug: str) -> ConsistencyReport: def resolve_repo_path(repo: dict, override: str | None = None) -> str:
"""Resolve the local filesystem path for a repo on the current machine.
Priority:
1. Explicit --repo-path CLI override
2. host_paths[current_hostname] — per-machine path registered via POST /repos/{slug}/paths/
3. local_path — legacy single-path field (backward compat)
"""
if override:
return override
hostname = socket.gethostname()
host_paths = repo.get("host_paths") or {}
return host_paths.get(hostname) or repo.get("local_path") or ""
def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = None) -> ConsistencyReport:
"""Run all consistency checks for a registered repo.""" """Run all consistency checks for a registered repo."""
repo = _api_get(api_base, f"/repos/{repo_slug}") repo = _api_get(api_base, f"/repos/{repo_slug}")
if repo is None: if repo is None:
@@ -307,7 +323,7 @@ def check_repo(api_base: str, repo_slug: str) -> ConsistencyReport:
return report return report
repo_id: str = repo["id"] repo_id: str = repo["id"]
repo_path: str = repo.get("local_path") or "" repo_path: str = resolve_repo_path(repo, repo_path_override)
report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path) report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path)
if not repo_path: if not repo_path:
@@ -607,9 +623,9 @@ def _check_orphan_db(
# Fix engine # Fix engine
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def fix_repo(api_base: str, repo_slug: str) -> ConsistencyReport: def fix_repo(api_base: str, repo_slug: str, repo_path_override: str | None = None) -> ConsistencyReport:
"""Run checks then apply all auto-fixable issues. Returns updated report.""" """Run checks then apply all auto-fixable issues. Returns updated report."""
report = check_repo(api_base, repo_slug) report = check_repo(api_base, repo_slug, repo_path_override)
fixable = [i for i in report.issues if i.fixable] fixable = [i for i in report.issues if i.fixable]
for issue in fixable: for issue in fixable:
@@ -866,6 +882,9 @@ def main() -> None:
help="Run checks against all registered repos with local_path") help="Run checks against all registered repos with local_path")
parser.add_argument("--fix", action="store_true", parser.add_argument("--fix", action="store_true",
help="Apply auto-fixable issues (status drift, repo mismatch, etc.)") help="Apply auto-fixable issues (status drift, repo mismatch, etc.)")
parser.add_argument("--repo-path", metavar="PATH", default=None,
help="Override the local repo path (useful when the DB has a different "
"machine's path). Takes priority over host_paths and local_path.")
parser.add_argument("--api-base", default="http://127.0.0.1:8000", parser.add_argument("--api-base", default="http://127.0.0.1:8000",
help="State Hub API base URL") help="State Hub API base URL")
parser.add_argument("--json", action="store_true", dest="as_json", parser.add_argument("--json", action="store_true", dest="as_json",
@@ -887,7 +906,9 @@ def main() -> None:
repo_slugs = [args.repo] repo_slugs = [args.repo]
runner = fix_repo if args.fix else check_repo runner = fix_repo if args.fix else check_repo
reports = [runner(args.api_base, slug) for slug in repo_slugs] # --repo-path only applies to single-repo runs; silently ignored with --all
path_override = args.repo_path if not args.all else None
reports = [runner(args.api_base, slug, path_override) for slug in repo_slugs]
if args.as_json: if args.as_json:
output = ( output = (