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:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user