diff --git a/state-hub/Makefile b/state-hub/Makefile index 7dce318..33ae222 100644 --- a/state-hub/Makefile +++ b/state-hub/Makefile @@ -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= PATH="; exit 1) + @test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO= 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= [DOMAIN=]"; 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="; 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="; 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: diff --git a/state-hub/api/models/managed_repo.py b/state-hub/api/models/managed_repo.py index 9823114..8ce6027 100644 --- a/state-hub/api/models/managed_repo.py +++ b/state-hub/api/models/managed_repo.py @@ -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") diff --git a/state-hub/api/routers/repos.py b/state-hub/api/routers/repos.py index a9587b4..a600fe8 100644 --- a/state-hub/api/routers/repos.py +++ b/state-hub/api/routers/repos.py @@ -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, diff --git a/state-hub/api/schemas/managed_repo.py b/state-hub/api/schemas/managed_repo.py index 48e534b..d549bd3 100644 --- a/state-hub/api/schemas/managed_repo.py +++ b/state-hub/api/schemas/managed_repo.py @@ -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 diff --git a/state-hub/mcp_server/TOOLS.md b/state-hub/mcp_server/TOOLS.md index edbe121..dd794f5 100644 --- a/state-hub/mcp_server/TOOLS.md +++ b/state-hub/mcp_server/TOOLS.md @@ -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`. | --- diff --git a/state-hub/mcp_server/server.py b/state-hub/mcp_server/server.py index 83225c5..49225dc 100644 --- a/state-hub/mcp_server/server.py +++ b/state-hub/mcp_server/server.py @@ -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 # --------------------------------------------------------------------------- diff --git a/state-hub/migrations/versions/g4b5c6d7e8f9_add_host_paths_to_repos.py b/state-hub/migrations/versions/g4b5c6d7e8f9_add_host_paths_to_repos.py new file mode 100644 index 0000000..90fa1ce --- /dev/null +++ b/state-hub/migrations/versions/g4b5c6d7e8f9_add_host_paths_to_repos.py @@ -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") diff --git a/state-hub/scripts/consistency_check.py b/state-hub/scripts/consistency_check.py index 6bf12e2..d7e897b 100644 --- a/state-hub/scripts/consistency_check.py +++ b/state-hub/scripts/consistency_check.py @@ -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 = (