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