711 lines
28 KiB
Python
711 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""Skill Sync client -- the low-level sync layer.
|
|
|
|
Builds content-addressed objects from local skills and talks the sync wire
|
|
contract (push objects + CAS a ref, pull the owner's HEAD, three-way merge on
|
|
a 409). Driven by the debounced push hook in ``skill_manage``, the periodic
|
|
``maybe_pull_skills`` at the curator tick sites, and the ``hermes sync`` CLI.
|
|
Lives under tools/ (NOT hermes_cli/) so it never imports the CLI at module
|
|
load. ``skills_sync_client_wire`` (object model + HTTP client + tree walks) and
|
|
``skills_sync_client_org`` (org-shared skills) are re-exported here.
|
|
|
|
ACCESS GATE (pre-launch): sync is INERT unless the signed-in user is a Nous
|
|
admin, read off the ``tool_gateway_admin`` JWT claim. The claim name is NAS's
|
|
and misleading: it is the global portal-admin permission, NOT a tool-gateway
|
|
right. Replace with a real entitlement before shipping to users.
|
|
|
|
OPT-IN DEFAULT (provisional): nothing syncs unless the user marks a skill for
|
|
sync. Local intent is the ``sync`` flag in ``.usage.json``; the DURABLE
|
|
cross-device state is the committed ``sync-manifest`` blob in the plane. Only
|
|
agent-created + user-authored skills under ``~/.hermes/skills/`` are eligible.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
from contextlib import suppress
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
from tools.skills_sync_client_wire import ( # noqa: F401 (re-exports)
|
|
ARTIFACT_TYPE_SKILL, DEFAULT_MAX_OBJECT_BYTES, KIND_BLOB, KIND_COMMIT, KIND_TREE, MODE_DIR,
|
|
MODE_EXEC, MODE_FILE, SYNC_MANIFEST_ENTRY_NAME, SYNC_MANIFEST_TYPE, SYNC_MANIFEST_VERSION,
|
|
WIRE_VERSION, ObjectSet, SyncClient, SyncConflict, SyncError, _check_version,
|
|
assemble_root_from_skill_trees, build_commit, build_root_tree, build_sync_manifest_bytes,
|
|
build_tree, canonical_json_bytes, materialize_tree, merge_skill, nest_skill_tree,
|
|
parse_sync_manifest, read_manifest_of_root, read_ref_hash, root_tree_of_commit,
|
|
skill_trees_of_root, wire_address,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_merge_skill = merge_skill
|
|
_skill_trees_of_root = skill_trees_of_root
|
|
|
|
|
|
# Identity & access gate. The bearer comes from resolve_nous_runtime_credentials()
|
|
# (file lock, host allowlist, refresh -- not reimplemented); its payload is
|
|
# decoded unverified to read the gate claim.
|
|
|
|
# Wire claim name is NAS's; it means "Nous admin" (Permissions.ADMIN_ACCESS).
|
|
NOUS_ADMIN_CLAIM = "tool_gateway_admin"
|
|
|
|
|
|
class SyncInertError(RuntimeError):
|
|
"""Sync must no-op: not logged in, no bearer, or not a Nous admin.
|
|
Caught by the gate-and-swallow hooks."""
|
|
|
|
|
|
def resolve_identity() -> Dict[str, Any]:
|
|
"""Resolve ``{api_key, base_url, owner, nous_admin, claims}``; raises
|
|
SyncInertError if not logged in / no bearer. ``owner`` is advisory for local
|
|
ref naming only -- the server derives the real owner from the bearer.
|
|
|
|
The JWT payload is decoded WITHOUT signature verification. Safe: the claims
|
|
are never used for authz here (the server re-verifies); they only decide
|
|
whether to attempt sync at all.
|
|
"""
|
|
try:
|
|
from hermes_cli.auth import resolve_nous_runtime_credentials
|
|
|
|
creds = resolve_nous_runtime_credentials() or {}
|
|
except Exception as e:
|
|
raise SyncInertError(f"no Nous credentials: {e}") from e
|
|
|
|
api_key = creds.get("api_key")
|
|
if not api_key:
|
|
raise SyncInertError("no bearer token available")
|
|
|
|
try:
|
|
import jwt # PyJWT, a core dependency
|
|
|
|
claims = jwt.decode(api_key, options={"verify_signature": False, "verify_exp": False}) or {}
|
|
except Exception as e:
|
|
logger.debug("skills_sync_client: JWT payload decode failed: %s", e)
|
|
claims = {}
|
|
owner = claims.get("sub") or claims.get("privy_did") or claims.get("tid") or "unknown"
|
|
return {
|
|
"api_key": api_key,
|
|
"base_url": creds.get("base_url"),
|
|
"owner": str(owner),
|
|
"nous_admin": claims.get(NOUS_ADMIN_CLAIM) is True,
|
|
"claims": claims,
|
|
}
|
|
|
|
|
|
# Configuration -- env-first so a Hermes Cloud instance can enable sync purely
|
|
# through environment variables. Every knob: HERMES_SYNC_<KEY> env -> config.yaml
|
|
# ``sync.<key>`` -> built-in default (keys: base_url = the sync plane, NOT the
|
|
# inference URL; enabled; default_opt_in; org_auto_propose).
|
|
|
|
#: Production Skill Sync plane; a normal user configures nothing.
|
|
DEFAULT_SYNC_BASE_URL = "https://gateway-gateway.nousresearch.com"
|
|
|
|
_TRUE = {"1", "true", "yes", "on"}
|
|
_FALSE = {"0", "false", "no", "off", ""}
|
|
|
|
|
|
def _sync_config(key: str) -> Any:
|
|
"""``sync.<key>`` from config.yaml, or None. Lazy import: this layer must not
|
|
import the CLI at module load."""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
|
|
return ((load_config() or {}).get("sync") or {}).get(key)
|
|
except Exception as e:
|
|
logger.debug("skills_sync_client: config sync.%s read failed: %s", key, e)
|
|
return None
|
|
|
|
|
|
def resolve_sync_base_url() -> Optional[str]:
|
|
"""HERMES_SYNC_BASE_URL -> ``sync.base_url`` -> production plane, without a
|
|
trailing slash (``/v1/sync/`` is appended by the client). None only if the
|
|
default is blanked out."""
|
|
env = os.getenv("HERMES_SYNC_BASE_URL")
|
|
if env and env.strip():
|
|
return env.strip().rstrip("/")
|
|
base = _sync_config("base_url")
|
|
if isinstance(base, str) and base.strip():
|
|
return base.strip().rstrip("/")
|
|
return DEFAULT_SYNC_BASE_URL or None
|
|
|
|
|
|
def _parse_bool(value: Any) -> Optional[bool]:
|
|
"""Parse a config/env bool; None if unrecognized so callers fall through to
|
|
the next precedence layer."""
|
|
if isinstance(value, bool):
|
|
return value
|
|
if value is None:
|
|
return None
|
|
s = str(value).strip().lower()
|
|
return True if s in _TRUE else False if s in _FALSE else None
|
|
|
|
|
|
def _sync_config_bool(env_var: str, config_key: str, *, default: bool) -> bool:
|
|
"""``env_var`` -> ``sync.<config_key>`` -> default."""
|
|
env_val = _parse_bool(os.getenv(env_var))
|
|
if env_val is not None:
|
|
return env_val
|
|
cfg_val = _parse_bool(_sync_config(config_key))
|
|
return default if cfg_val is None else cfg_val
|
|
|
|
|
|
def sync_feature_enabled() -> bool:
|
|
"""Master switch. Checked by the gate-and-swallow entrypoints IN ADDITION to
|
|
the Nous-admin gate and a configured base URL -- all three must hold."""
|
|
return _sync_config_bool("HERMES_SYNC_ENABLED", "enabled", default=False)
|
|
|
|
|
|
def sync_org_auto_propose() -> bool:
|
|
"""False (default): edits to an org skill stay LOCAL until ``hermes sync
|
|
propose``. True: every edit is proposed right away (an admin still approves
|
|
unless the editor is one) -- for small high-trust teams."""
|
|
return _sync_config_bool("HERMES_SYNC_ORG_AUTO_PROPOSE", "org_auto_propose", default=False)
|
|
|
|
|
|
def sync_default_opt_in() -> bool:
|
|
"""False (default): opt-IN -- a skill syncs only after ``hermes sync enable``
|
|
or a plane manifest opting it in. True: opt-OUT -- every eligible skill
|
|
syncs unless explicitly disabled (the Hermes Cloud default). Provisional."""
|
|
return _sync_config_bool("HERMES_SYNC_DEFAULT_OPT_IN", "default_opt_in", default=False)
|
|
|
|
|
|
# Local skill eligibility + the personal opt-in flag
|
|
|
|
def _skills_dir() -> Path:
|
|
from hermes_constants import get_hermes_home
|
|
|
|
return get_hermes_home() / "skills"
|
|
|
|
|
|
def _org_dir() -> Path:
|
|
"""Local mirror root for org skills (read-only by convention)."""
|
|
return _skills_dir() / ORG_DIR_NAME
|
|
|
|
|
|
def _rel_to_skills_dir(skill_dir: Path) -> Optional[Path]:
|
|
"""*skill_dir* relative to ~/.hermes/skills/, or None if outside/unresolvable."""
|
|
try:
|
|
return skill_dir.resolve().relative_to(_skills_dir().resolve())
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def _skill_rel_path(skill_name: str) -> Optional[PurePosixPath]:
|
|
"""The skill's path relative to ~/.hermes/skills/ (posix), or None."""
|
|
try:
|
|
from tools.skill_usage import _find_skill_dir
|
|
except Exception:
|
|
return None
|
|
skill_dir = _find_skill_dir(skill_name)
|
|
rel = _rel_to_skills_dir(skill_dir) if skill_dir is not None else None
|
|
return PurePosixPath(rel.as_posix()) if rel is not None else None
|
|
|
|
|
|
def is_sync_eligible(skill_name: str) -> bool:
|
|
"""Candidate for sync (before the opt-in check): present locally, NOT
|
|
bundled, NOT hub-installed, NOT external, and NOT under the ``_org/`` mirror
|
|
(enterprise content must never ride a personal push). Mirrors the curator's
|
|
exclusions (tools/skill_usage.py)."""
|
|
try:
|
|
from tools.skill_usage import is_bundled, is_hub_installed, _find_skill_dir
|
|
from agent.skill_utils import is_external_skill_path
|
|
except Exception:
|
|
return False
|
|
if is_bundled(skill_name) or is_hub_installed(skill_name):
|
|
return False
|
|
skill_dir = _find_skill_dir(skill_name)
|
|
if skill_dir is None or is_external_skill_path(skill_dir):
|
|
return False
|
|
rel = _rel_to_skills_dir(skill_dir)
|
|
return not (rel is not None and rel.parts and rel.parts[0] == ORG_DIR_NAME)
|
|
|
|
|
|
def list_synced_skill_names() -> List[str]:
|
|
"""Names of skills that should sync (sorted, deduped), per ``sync_default_opt_in()``:
|
|
opt-in -> only eligible skills whose usage record has ``sync: true``;
|
|
opt-out -> every eligible skill unless its record has ``sync: false``."""
|
|
try:
|
|
from tools.skill_usage import load_usage
|
|
except Exception:
|
|
return []
|
|
usage = load_usage() or {}
|
|
flags = {n: rec.get("sync") for n, rec in usage.items() if isinstance(rec, dict)}
|
|
if sync_default_opt_in():
|
|
names = [n for n in _all_local_skill_names() if flags.get(n) is not False and is_sync_eligible(n)]
|
|
else:
|
|
names = [n for n, f in flags.items() if f is True and is_sync_eligible(n)]
|
|
return sorted(set(names))
|
|
|
|
|
|
def _all_local_skill_names() -> List[str]:
|
|
"""Every locally-present skill name (a dir under ~/.hermes/skills/ with a
|
|
``SKILL.md``; frontmatter ``name`` falling back to the dir name). Eligibility
|
|
is applied by the caller."""
|
|
names: List[str] = []
|
|
root = _skills_dir()
|
|
try:
|
|
for skill_md in root.rglob("SKILL.md") if root.exists() else ():
|
|
if skill_md.is_symlink():
|
|
continue
|
|
name = skill_md.parent.name
|
|
try:
|
|
from tools.skill_usage import _read_skill_name
|
|
|
|
name = _read_skill_name(skill_md, name)
|
|
except Exception:
|
|
pass
|
|
if name:
|
|
names.append(name)
|
|
except OSError as e:
|
|
logger.debug("skills_sync_client: local skill enumeration failed: %s", e)
|
|
return sorted(set(names))
|
|
|
|
|
|
def _opted_in_rel_paths() -> List[str]:
|
|
"""Relative posix paths of skills the user has opted into sync."""
|
|
rels = (_skill_rel_path(name) for name in list_synced_skill_names())
|
|
return [rel.as_posix() for rel in rels if rel is not None]
|
|
|
|
|
|
def _adopt_manifest_opt_ins(remote_manifest: Optional[Dict[str, bool]]) -> List[str]:
|
|
"""Enable local sync intent for skills the plane manifest has enabled and that
|
|
are locally curation-eligible. Enables only -- a pull never silently disables.
|
|
Returns the adopted names; best-effort."""
|
|
adopted: List[str] = []
|
|
if not remote_manifest:
|
|
return adopted
|
|
try:
|
|
from tools.skill_usage import set_sync, is_curation_eligible, is_sync_enabled
|
|
|
|
for sname, enabled in remote_manifest.items():
|
|
if enabled and is_curation_eligible(sname) and not is_sync_enabled(sname):
|
|
set_sync(sname, True)
|
|
adopted.append(sname)
|
|
except Exception as e:
|
|
logger.debug("skills_sync_client: manifest opt-in reconcile failed: %s", e)
|
|
return adopted
|
|
|
|
|
|
# Device label (commit ``author.device``; advisory, never an auth input)
|
|
|
|
def _default_device_label() -> str:
|
|
"""Short hostname + short random suffix (two machines can share a hostname);
|
|
a bare uuid if the hostname is unusable."""
|
|
import socket
|
|
import uuid
|
|
|
|
try:
|
|
host = socket.gethostname() or ""
|
|
except OSError:
|
|
host = ""
|
|
short = "".join(c for c in host.split(".")[0].strip() if c.isalnum() or c in "-_")
|
|
return f"{short}-{uuid.uuid4().hex[:6]}" if short else uuid.uuid4().hex
|
|
|
|
|
|
def stable_device_id() -> str:
|
|
"""Stable per-device label, persisted at ~/.hermes/skills/.sync_device_id.
|
|
An existing file always wins. Otherwise seeded from HERMES_SYNC_DEVICE_NAME
|
|
(first use only; lets Hermes Cloud name hosted instances) or a friendly
|
|
default, then persisted so a later ``set_device_name()`` still wins."""
|
|
path = _skills_dir() / ".sync_device_id"
|
|
try:
|
|
if path.exists():
|
|
val = path.read_text(encoding="utf-8").strip()
|
|
if val:
|
|
return val
|
|
except OSError:
|
|
pass
|
|
val = (os.environ.get("HERMES_SYNC_DEVICE_NAME") or "").strip() or _default_device_label()
|
|
try:
|
|
_write_device_id(val)
|
|
except OSError as e:
|
|
logger.debug("skills_sync_client: could not persist device id: %s", e)
|
|
return val
|
|
|
|
|
|
def _write_device_id(val: str) -> None:
|
|
path = _skills_dir() / ".sync_device_id"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(val, encoding="utf-8")
|
|
|
|
|
|
def set_device_name(name: str) -> str:
|
|
"""Overwrite the device label with the trimmed *name*; any non-empty string
|
|
is accepted. Returns the stored value; ValueError on empty."""
|
|
cleaned = (name or "").strip()
|
|
if not cleaned:
|
|
raise ValueError("device name must be a non-empty string")
|
|
_write_device_id(cleaned)
|
|
return cleaned
|
|
|
|
|
|
# Local sync STATE: the last HEAD we pushed/pulled + the root tree at that
|
|
# point (FULL-digest namespace). Distinct from the bundled manifest
|
|
# (skills_sync.py) and from the plane's `sync-manifest` object. Lives at
|
|
# ~/.hermes/skills/.sync_state; a legacy `.sync_manifest` is migrated on read.
|
|
|
|
_EMPTY_STATE: Dict[str, Any] = {"head": None, "skills": {}}
|
|
|
|
|
|
def _sync_state_path() -> Path:
|
|
return _skills_dir() / ".sync_state"
|
|
|
|
|
|
def _load_state_file(path: Path, what: str = "sync state read") -> Optional[Dict[str, Any]]:
|
|
"""Parse a state file; None if missing / corrupt / not a dict."""
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.debug("skills_sync_client: %s failed: %s", what, e)
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
data.setdefault("head", None)
|
|
data.setdefault("skills", {})
|
|
return data
|
|
|
|
|
|
def read_sync_state() -> Dict[str, Any]:
|
|
"""``{"head": "sha256:...|null", "skills": {...}}``; a default on missing/corrupt.
|
|
If ``.sync_state`` is absent but the legacy ``.sync_manifest`` exists, it is
|
|
read, rewritten to the new path and removed, so no head record is lost."""
|
|
path = _sync_state_path()
|
|
if path.exists():
|
|
return _load_state_file(path) or dict(_EMPTY_STATE)
|
|
legacy = _skills_dir() / ".sync_manifest"
|
|
if legacy.exists():
|
|
data = _load_state_file(legacy, "legacy sync state migrate")
|
|
if data is not None:
|
|
write_sync_state(data)
|
|
with suppress(OSError):
|
|
legacy.unlink()
|
|
return data
|
|
return dict(_EMPTY_STATE)
|
|
|
|
|
|
def write_sync_state(data: Dict[str, Any]) -> None:
|
|
"""Write the local sync state atomically. Best-effort."""
|
|
path = _sync_state_path()
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".sync_state_", suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
with suppress(OSError):
|
|
os.unlink(tmp)
|
|
raise
|
|
except Exception as e:
|
|
logger.debug("skills_sync_client: sync state write failed: %s", e)
|
|
|
|
|
|
def _record_head(state: Dict[str, Any], head: str, root: str) -> None:
|
|
state["head"] = head
|
|
state["root"] = root
|
|
write_sync_state(state)
|
|
|
|
|
|
# Profile snapshot -- the root tree mirrors each synced skill's relative path
|
|
# under ~/.hermes/skills/ (category dirs become intermediate trees).
|
|
|
|
def snapshot_profile(
|
|
skill_names: List[str], *, max_object_bytes: int = DEFAULT_MAX_OBJECT_BYTES
|
|
) -> Tuple[ObjectSet, str, Dict[str, str]]:
|
|
"""Build all objects for *skill_names* + the profile-root tree; returns
|
|
``(objects, root_tree_hash, {skill_name: tree_hash})``. Skills whose blobs
|
|
exceed *max_object_bytes* are skipped (logged). The root also carries the
|
|
``sync-manifest`` blob listing every included skill as ``enabled: true``."""
|
|
from tools.skill_usage import _find_skill_dir
|
|
|
|
objects = ObjectSet()
|
|
skill_tree_map: Dict[str, str] = {}
|
|
root: Dict[str, Any] = {}
|
|
for name in sorted(set(skill_names)):
|
|
rel = _skill_rel_path(name)
|
|
skill_dir = _find_skill_dir(name)
|
|
if rel is None or skill_dir is None:
|
|
continue
|
|
try:
|
|
tree_hash = build_tree(skill_dir, objects, max_object_bytes=max_object_bytes)
|
|
except ValueError as e:
|
|
logger.warning("skills_sync_client: skipping %s: %s", name, e)
|
|
continue
|
|
skill_tree_map[name] = tree_hash
|
|
nest_skill_tree(root, rel.parts, tree_hash)
|
|
|
|
manifest_hash = objects.add(KIND_BLOB, build_sync_manifest_bytes(dict.fromkeys(skill_tree_map, True)))
|
|
return objects, build_root_tree(root, objects, manifest_hash=manifest_hash), skill_tree_map
|
|
|
|
|
|
# Personal refs, push, pull
|
|
|
|
def user_head_ref(owner: str) -> str:
|
|
return f"refs/user/{owner}/HEAD"
|
|
|
|
|
|
def _personal_client(
|
|
identity: Optional[Dict[str, Any]], client: Optional[SyncClient]
|
|
) -> Tuple[Dict[str, Any], Optional[SyncClient]]:
|
|
"""Resolve identity + client for a personal sync op. ``client`` is None when
|
|
no base URL is configured (callers return a no-op result)."""
|
|
identity = identity if identity is not None else resolve_identity()
|
|
if client is None:
|
|
base = resolve_sync_base_url()
|
|
client = SyncClient(base, identity["api_key"]) if base else None
|
|
return identity, client
|
|
|
|
|
|
_NO_BASE_URL = {"ok": False, "reason": "no sync base url configured", "noop": True}
|
|
|
|
|
|
def push_skills(
|
|
client: Optional[SyncClient] = None,
|
|
*,
|
|
skill_names: Optional[List[str]] = None,
|
|
identity: Optional[Dict[str, Any]] = None,
|
|
message: str = "hermes skill sync",
|
|
) -> Dict[str, Any]:
|
|
"""Push opted-in skills to ``refs/user/<owner>/HEAD``: upload new objects, CAS
|
|
HEAD. A 409 with an actual head -> three-way merge + one retry; a 409 against
|
|
a NON-EXISTENT ref (stale local head, e.g. carried over from another plane)
|
|
-> redo the CAS as a create. Never raises for the inert / no-op cases."""
|
|
identity, client = _personal_client(identity, client)
|
|
if client is None:
|
|
return dict(_NO_BASE_URL)
|
|
owner = identity["owner"]
|
|
|
|
if skill_names is None:
|
|
skill_names = list_synced_skill_names()
|
|
if not skill_names:
|
|
return {"ok": True, "reason": "no skills opted into sync", "noop": True}
|
|
|
|
caps = client.capabilities()
|
|
_check_version(caps)
|
|
max_bytes = int(caps.get("max_object_bytes") or DEFAULT_MAX_OBJECT_BYTES)
|
|
objects, root_hash, _ = snapshot_profile(skill_names, max_object_bytes=max_bytes)
|
|
|
|
state = read_sync_state()
|
|
base_head = state.get("head")
|
|
# Idempotency: objects are immutable, so an unchanged root tree hash means
|
|
# identical content -- skip building an empty commit.
|
|
if base_head and state.get("root") == root_hash:
|
|
return {"ok": True, "head": base_head, "reason": "unchanged", "noop": True}
|
|
|
|
commit_hash = build_commit(
|
|
root_hash, [base_head] if base_head else [], owner=owner, device=stable_device_id(),
|
|
message=message, objects=objects,
|
|
)
|
|
client.put_objects(objects.objects)
|
|
ref = user_head_ref(owner)
|
|
result = {"ok": True, "head": commit_hash, "pushed_objects": len(objects)}
|
|
try:
|
|
client.cas_ref(ref, base_head, commit_hash)
|
|
except SyncConflict as conflict:
|
|
if conflict.actual:
|
|
return _resolve_push_conflict(
|
|
client, identity, conflict.actual, root_hash, commit_hash, objects, message, base_head
|
|
)
|
|
client.cas_ref(ref, None, commit_hash)
|
|
result["recovered_stale_head"] = True
|
|
_record_head(state, commit_hash, root_hash)
|
|
return result
|
|
|
|
|
|
# Three-way merge per skill against the base we forked from (see merge_skill):
|
|
# each side changing a DIFFERENT skill -> merge commit (2 parents) + CAS retry;
|
|
# both changing the SAME skill differently -> TRUE OVERLAP -> written to
|
|
# refs/user/<owner>/conflict/<n> and surfaced for out-of-band resolution.
|
|
def _resolve_push_conflict(
|
|
client: SyncClient,
|
|
identity: Dict[str, Any],
|
|
actual_head: str,
|
|
our_root: str,
|
|
our_commit: str,
|
|
objects: ObjectSet,
|
|
message: str,
|
|
base_head: Optional[str],
|
|
) -> Dict[str, Any]:
|
|
owner = identity["owner"]
|
|
ours_trees = skill_trees_of_root(client, our_root)
|
|
theirs_trees = skill_trees_of_root(client, root_tree_of_commit(client, actual_head))
|
|
base_trees = skill_trees_of_root(client, root_tree_of_commit(client, base_head)) if base_head else {}
|
|
|
|
merged: Dict[str, str] = {}
|
|
overlaps: List[str] = []
|
|
for path in set(ours_trees) | set(theirs_trees) | set(base_trees):
|
|
o, t = ours_trees.get(path), theirs_trees.get(path)
|
|
decision = merge_skill(base_trees.get(path), o, t)
|
|
if decision == "overlap":
|
|
overlaps.append(path)
|
|
# overlap keeps OURS on the surfaced conflict head (theirs stays
|
|
# server-side); "none" = deleted on the winning side -> drop.
|
|
pick = {"overlap": o, "ours": o, "theirs": t, "either": o if o is not None else t}.get(decision)
|
|
if pick is not None:
|
|
merged[path] = pick
|
|
|
|
if overlaps:
|
|
conflict_ref = f"refs/user/{owner}/conflict/{_next_conflict_index(client, owner)}"
|
|
try:
|
|
client.cas_ref(conflict_ref, None, our_commit)
|
|
except SyncConflict:
|
|
pass # someone else grabbed this index; the head still exists
|
|
return {
|
|
"ok": False, "conflict": True, "conflict_ref": conflict_ref,
|
|
"overlapping_skills": sorted(overlaps), "actual_head": actual_head,
|
|
"message": (
|
|
f"{len(overlaps)} skill(s) changed on both sides; wrote "
|
|
f"{conflict_ref}. Resolve out-of-band (hermes sync / NAS UI)."
|
|
),
|
|
}
|
|
|
|
# Merge commit (parents: actual, ours); re-add our objects so the merge
|
|
# push is self-contained.
|
|
merge_objects = ObjectSet()
|
|
merge_objects.objects.update(objects.objects)
|
|
merged_root = assemble_root_from_skill_trees(merged, merge_objects)
|
|
merge_commit = build_commit(
|
|
merged_root, [actual_head, our_commit], owner=owner, device=stable_device_id(),
|
|
message=f"merge: {message}", objects=merge_objects,
|
|
)
|
|
client.put_objects(merge_objects.objects)
|
|
try:
|
|
client.cas_ref(user_head_ref(owner), actual_head, merge_commit)
|
|
except SyncConflict as c2:
|
|
return {
|
|
"ok": False, "conflict": True, "actual_head": c2.actual,
|
|
"message": f"merge CAS lost again (head now {c2.actual}); retry sync.",
|
|
}
|
|
_record_head(read_sync_state(), merge_commit, merged_root)
|
|
return {"ok": True, "head": merge_commit, "merged": True}
|
|
|
|
|
|
def _next_conflict_index(client: SyncClient, owner: str) -> int:
|
|
"""Next free ``conflict/<n>`` index for the owner."""
|
|
try:
|
|
refs = client.get_refs(f"refs/user/{owner}/conflict/")
|
|
except SyncError:
|
|
return 1
|
|
used = [int(t) for t in (r.get("name", "").rsplit("/", 1)[-1] for r in refs) if t.isdigit()]
|
|
return (max(used) + 1) if used else 1
|
|
|
|
|
|
def pull_skills(
|
|
client: Optional[SyncClient] = None, *, identity: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""Pull the owner's HEAD and materialize opted-in skills under ~/.hermes/skills/
|
|
if it advanced past our recorded head. Opt-in intent is first adopted FROM
|
|
the plane manifest, then only opted-in paths are written so a pull never
|
|
resurrects a skill the user hasn't chosen. Best-effort; returns a result dict."""
|
|
identity, client = _personal_client(identity, client)
|
|
if client is None:
|
|
return dict(_NO_BASE_URL)
|
|
owner = identity["owner"]
|
|
|
|
_check_version(client.capabilities())
|
|
head = read_ref_hash(client, user_head_ref(owner))
|
|
if not head:
|
|
return {"ok": True, "reason": "no remote HEAD yet", "noop": True}
|
|
|
|
state = read_sync_state()
|
|
if head == state.get("head"):
|
|
return {"ok": True, "reason": "already up to date", "head": head, "noop": True}
|
|
|
|
root_tree = root_tree_of_commit(client, head)
|
|
remote_trees = skill_trees_of_root(client, root_tree)
|
|
|
|
adopted = _adopt_manifest_opt_ins(read_manifest_of_root(client, root_tree))
|
|
opted_in = set(_opted_in_rel_paths())
|
|
updated = [path for path in remote_trees if not opted_in or path in opted_in]
|
|
for path in updated:
|
|
materialize_tree(client, remote_trees[path], _skills_dir() / path)
|
|
|
|
state["head"] = head
|
|
write_sync_state(state)
|
|
return {"ok": True, "head": head, "updated": sorted(updated), "opt_in_adopted": sorted(adopted)}
|
|
|
|
|
|
# Gated public entrypoints (gate-and-swallow, like the curator's
|
|
# maybe_run_curator): best-effort, never raise, return a result dict or None.
|
|
|
|
def _gate_and_swallow(op: str, run: Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]):
|
|
"""Run *run(identity)* only if every background-sync gate holds (Nous admin,
|
|
feature on, base URL); None when inert or on any error."""
|
|
try:
|
|
identity = resolve_identity()
|
|
if not identity.get("nous_admin") or not sync_feature_enabled() or not resolve_sync_base_url():
|
|
return None
|
|
return run(identity)
|
|
except Exception as e:
|
|
logger.debug("skills_sync_client: %s failed: %s", op, e, exc_info=True)
|
|
return None
|
|
|
|
|
|
def maybe_push_skills(*, message: str = "hermes skill sync") -> Optional[Dict[str, Any]]:
|
|
"""Best-effort push (debounced skill_manage hook). Never raises."""
|
|
return _gate_and_swallow(
|
|
"maybe_push_skills",
|
|
lambda identity: push_skills(identity=identity, message=message) if list_synced_skill_names() else None,
|
|
)
|
|
|
|
|
|
def maybe_pull_skills() -> Optional[Dict[str, Any]]:
|
|
"""Best-effort pull (curator tick sites: gateway housekeeping + CLI startup).
|
|
Never raises."""
|
|
return _gate_and_swallow("maybe_pull_skills", lambda identity: pull_skills(identity=identity))
|
|
|
|
|
|
def sync_status() -> Dict[str, Any]:
|
|
"""Status snapshot for ``hermes sync status``. Never raises.
|
|
``org_available`` False means the account isn't in a shared organisation
|
|
(the org workflow does not apply), not that anything is broken."""
|
|
status: Dict[str, Any] = {
|
|
"nous_admin": False, "logged_in": False, "feature_enabled": sync_feature_enabled(),
|
|
"default_opt_in": sync_default_opt_in(), "base_url": resolve_sync_base_url(),
|
|
"opted_in_skills": [], "local_head": None, "owner": None, "org_available": False,
|
|
"org_id": None, "org_role": None, "org_skills": [], "org_skills_modified": [],
|
|
}
|
|
|
|
try:
|
|
identity = resolve_identity()
|
|
status.update(logged_in=True, owner=identity.get("owner"), nous_admin=bool(identity.get("nous_admin")))
|
|
except SyncInertError:
|
|
pass
|
|
except Exception as e:
|
|
logger.debug("skills_sync_client: sync_status identity failed: %s", e)
|
|
try:
|
|
status["opted_in_skills"] = list_synced_skill_names()
|
|
status["local_head"] = read_sync_state().get("head")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
org_identity = resolve_org_identity()
|
|
status.update(
|
|
org_available=True, org_id=org_identity.get("org_id"), org_role=org_identity.get("org_role"),
|
|
org_skills=list_org_skill_names(),
|
|
org_skills_modified=list_locally_modified_org_skills(org_identity.get("org_id")),
|
|
)
|
|
except SyncInertError:
|
|
pass
|
|
except Exception as e:
|
|
logger.debug("skills_sync_client: sync_status org lookup failed: %s", e)
|
|
return status
|
|
|
|
|
|
# Org-shared skills live in skills_sync_client_org; imported last because that
|
|
# module reads this module's state lazily.
|
|
from tools.skills_sync_client_org import ( # noqa: E402,F401 (re-exports)
|
|
ORG_DIR_NAME, _ORG_CAS_MAX_ATTEMPTS, _clear_active_org_marker, _org_baseline_path,
|
|
_read_org_baseline, _read_org_head, _skill_dir_fingerprint, _write_active_org_marker,
|
|
_write_org_baseline, _write_org_provenance, list_locally_modified_org_skills,
|
|
list_org_skill_names, maybe_pull_org_skills, org_head_ref, org_skill_is_locally_modified,
|
|
propose_skill, pull_org_skills, resolve_org_identity,
|
|
)
|