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 d66f23026d
commit 82552b8d59
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
@@ -135,6 +135,14 @@ rename-domain:
-H "Content-Type: application/json" \
-d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool
## Register this machine's local path for a repo: make register-path REPO=marki-docx PATH=/home/tegwick/marki-docx
register-path:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
@test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths/" \
-H "Content-Type: application/json" \
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool
## List repos for a domain: make list-repos DOMAIN=railiance
list-repos:
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
@@ -166,15 +174,19 @@ validate-adr:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1)
uv run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",)
## 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:
@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:
@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-consistency-all:

View File

@@ -2,7 +2,7 @@ import uuid
from datetime import datetime
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 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)
name: Mapped[str] = mapped_column(String(200), nullable=False)
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)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")

View File

@@ -15,6 +15,7 @@ from api.schemas.managed_repo import (
DispatchWorkstream,
RepoCreate,
RepoDispatch,
RepoPathRegister,
RepoRead,
RepoUpdate,
)
@@ -89,6 +90,26 @@ async def update_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)
async def archive_repo(
slug: str,

View File

@@ -24,6 +24,12 @@ class RepoUpdate(BaseModel):
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):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
@@ -32,6 +38,7 @@ class RepoRead(BaseModel):
slug: str
name: str
local_path: str | None = None
host_paths: dict = {}
remote_url: str | None = None
description: str | None = None
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. |
| `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. |
| `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)
@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
# ---------------------------------------------------------------------------

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 json
import re
import socket
import sys
from dataclasses import dataclass, field
from pathlib import Path
@@ -294,7 +295,22 @@ def _api_post(api_base: str, path: str, body: dict) -> Any:
# 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."""
repo = _api_get(api_base, f"/repos/{repo_slug}")
if repo is None:
@@ -307,7 +323,7 @@ def check_repo(api_base: str, repo_slug: str) -> ConsistencyReport:
return report
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)
if not repo_path:
@@ -607,9 +623,9 @@ def _check_orphan_db(
# 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."""
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]
for issue in fixable:
@@ -866,6 +882,9 @@ def main() -> None:
help="Run checks against all registered repos with local_path")
parser.add_argument("--fix", action="store_true",
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",
help="State Hub API base URL")
parser.add_argument("--json", action="store_true", dest="as_json",
@@ -887,7 +906,9 @@ def main() -> None:
repo_slugs = [args.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:
output = (