mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
refactor(sync): put every Skill Sync verb under hermes sync; drop HSP naming
Encapsulates the feature behind one command for launch, and adopts the
official product name.
One command:
- `propose` moves from `hermes skills propose` to `hermes sync propose`, so
the whole feature is one command to learn and one to document. Its handler
moves from cmd_skills to cmd_sync accordingly.
- The `hermes sync` parser now documents both halves plainly: personal sync
across your devices, and sharing with your organisation. Added an examples
epilog; rewrote the verb help in user language ("Include a skill in your
sync" rather than "Opt a skill into sync").
- Every user-facing string that pointed at `hermes skills propose` now points
at `hermes sync propose` (8 sites, including the agent-visible guidance
returned by skill_manage and the org provenance header).
This also clears the way for #39343, which adds its own top-level `sync` for
git-repo profile backup — that feature nests under `skills`, this one owns
`sync`.
Naming:
- HSP / "Hermes Sync Protocol" is gone from prose, docstrings, and comments.
The feature is "Skill Sync".
- Public identifiers renamed: HSPClient -> SyncClient, HSPError -> SyncError,
HSPConflict -> SyncConflict, hsp_address -> wire_address, HSP_VERSION ->
WIRE_VERSION.
- The WIRE names are deliberately NOT renamed: the `hsp_version` capability
field and the `x-hsp-object-type` response header are set by the deployed
gateway-gateway sync plane (verified in src/sync/syncRouter.ts), so
renaming them client-side would break sync against a live server. A comment
at the version constant records why they differ from the product name.
- The version-mismatch error is now actionable ("this server speaks sync
version X, but this Hermes speaks Y — update Hermes to sync with it")
instead of leaking the protocol acronym.
Also fixes a wiring gap found on the way: the gateway housekeeping tick
pulled personal skills but never org skills — the same defect already fixed
for the CLI. Org pull now runs there too, gated on real org membership.
Tests: the jargon guard now also fails on a bare "HSP". The two tests that
asserted the old cross-command structure are replaced by three asserting the
new one (propose IS under sync, propose is NOT under skills, sync usage
lists it). 2294 passed / 0 failed across all 51 suites that import the
changed modules, via scripts/run_tests.sh.
Verified by running the real CLI: `hermes sync --help` lists all eight verbs,
`hermes skills --help` no longer mentions propose, `hermes sync propose
--help` parses, and `hermes sync status` still reports live org state.
This commit is contained in:
parent
981feb6730
commit
4f990ec09e
10 changed files with 240 additions and 230 deletions
|
|
@ -22830,15 +22830,23 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop
|
|||
except Exception as e:
|
||||
logger.debug("Curator tick error: %s", e)
|
||||
|
||||
# HSP skill sync — best-effort periodic pull on the same cadence.
|
||||
# Inert unless the DEV-PHASE gate is open (tool_gateway_admin) and
|
||||
# a sync base URL is configured; never raises.
|
||||
# Skill Sync — best-effort periodic pull on the same cadence.
|
||||
# Inert unless the access gate is open and a sync base URL is
|
||||
# configured; never raises.
|
||||
try:
|
||||
from tools.skills_sync_client import maybe_pull_skills
|
||||
maybe_pull_skills()
|
||||
except Exception as e:
|
||||
logger.debug("Sync pull tick error: %s", e)
|
||||
|
||||
# Org-shared skills. Gated on real org membership (the token must
|
||||
# carry an org role), so a solo account never reaches the network.
|
||||
try:
|
||||
from tools.skills_sync_client import maybe_pull_org_skills
|
||||
maybe_pull_org_skills()
|
||||
except Exception as e:
|
||||
logger.debug("Org sync pull tick error: %s", e)
|
||||
|
||||
stop_event.wait(timeout=interval)
|
||||
logger.info("Gateway housekeeping stopped")
|
||||
|
||||
|
|
|
|||
|
|
@ -4440,26 +4440,27 @@ def cmd_cron(args):
|
|||
|
||||
|
||||
def cmd_sync(args):
|
||||
"""HSP/1 personal skill sync management (status/pull/push/now/enable/disable)."""
|
||||
"""Skill Sync — personal sync across devices, plus sharing with your org."""
|
||||
import json as _json
|
||||
|
||||
sub = getattr(args, "sync_command", None)
|
||||
|
||||
if sub in {None, ""}:
|
||||
print(
|
||||
"usage: hermes sync <status|pull|push|now|enable|disable|device>\n"
|
||||
"usage: hermes sync "
|
||||
"<status|pull|push|now|enable|disable|device|propose>\n"
|
||||
"\n"
|
||||
" status Show sync gate, opt-in, and head state\n"
|
||||
" pull Pull the owner's HEAD, materialize opted-in skills\n"
|
||||
" push Push opted-in skills to the owner's HEAD\n"
|
||||
"Your skills, across your devices:\n"
|
||||
" status Show what is synced, and from where\n"
|
||||
" pull Pull your synced skills\n"
|
||||
" push Push your opted-in skills\n"
|
||||
" now Reconcile now: pull then push\n"
|
||||
" enable <skill> Opt a skill into sync\n"
|
||||
" disable <skill> Opt a skill out of sync\n"
|
||||
" device [--name N] Show or set this device's sync label\n"
|
||||
" enable <skill> Include a skill in your sync\n"
|
||||
" disable <skill> Exclude a skill from your sync\n"
|
||||
" device [--name N] Show or set this device's label\n"
|
||||
"\n"
|
||||
"These cover your PERSONAL skills, across your own devices.\n"
|
||||
"To share a skill with your organisation instead:\n"
|
||||
" hermes skills propose <skill>",
|
||||
"Shared with your team:\n"
|
||||
" propose <skill> Share a skill with your organisation",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
|
@ -4485,6 +4486,28 @@ def cmd_sync(args):
|
|||
print(ssc.stable_device_id())
|
||||
return 0
|
||||
|
||||
if sub == "propose":
|
||||
from tools import skills_sync_client as ssc
|
||||
|
||||
name = args.name
|
||||
try:
|
||||
result = ssc.propose_skill(name, message=args.message)
|
||||
except ssc.SyncInertError as e:
|
||||
print(f"cannot share this skill: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except ssc.SyncError as e:
|
||||
print(f"could not share '{name}': {e}", file=sys.stderr)
|
||||
return 1
|
||||
if result.get("proposal_pending"):
|
||||
print(
|
||||
f"Shared '{name}' with your organisation — an admin needs to "
|
||||
f"approve it (proposal #{result.get('proposal_id')}). It is "
|
||||
f"not live for the team until then."
|
||||
)
|
||||
else:
|
||||
print(f"Added '{name}' to your organisation's shared skills.")
|
||||
return 0
|
||||
|
||||
if sub in {"enable", "disable"}:
|
||||
from tools.skill_usage import set_sync, is_curation_eligible
|
||||
|
||||
|
|
@ -4519,7 +4542,7 @@ def cmd_sync(args):
|
|||
print(
|
||||
f" {len(modified)} with local edits not yet shared: "
|
||||
f"{', '.join(modified)}\n"
|
||||
f" Share them back with `hermes skills propose <skill>`. "
|
||||
f" Share them back with `hermes sync propose <skill>`. "
|
||||
f"Org updates will not overwrite them.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
|
@ -4603,7 +4626,7 @@ def cmd_sync(args):
|
|||
else:
|
||||
print(f"Unknown sync subcommand: {sub}", file=sys.stderr)
|
||||
return 1
|
||||
except ssc.HSPError as e:
|
||||
except ssc.SyncError as e:
|
||||
print(f"sync failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
|
@ -13942,34 +13965,6 @@ def cmd_skills(args):
|
|||
from hermes_cli.skills_config import skills_command as skills_config_command
|
||||
|
||||
skills_config_command(args)
|
||||
elif getattr(args, "skills_action", None) == "propose":
|
||||
# M2 org-shared skills (hsp-1-contract.md §11.5): propose a local
|
||||
# skill to the org canonical set. 202 => pending review (NEVER shown
|
||||
# as live); direct merge for admins. Personal orgs have no org
|
||||
# workflow — say so plainly instead of a raw 403.
|
||||
from tools import skills_sync_client as ssc
|
||||
|
||||
name = args.name
|
||||
try:
|
||||
result = ssc.propose_skill(name, message=args.message)
|
||||
except ssc.SyncInertError as e:
|
||||
print(f"org sync unavailable: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except ssc.HSPError as e:
|
||||
print(f"propose failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if result.get("proposal_pending"):
|
||||
print(
|
||||
f"proposed '{name}' — pending admin review "
|
||||
f"(proposal #{result.get('proposal_id')}). Not live for the "
|
||||
f"org until approved."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"merged '{name}' into the org set "
|
||||
f"(head {str(result.get('head', ''))[:19]}…)."
|
||||
)
|
||||
return 0
|
||||
else:
|
||||
from hermes_cli.skills_hub import skills_command
|
||||
|
||||
|
|
|
|||
|
|
@ -313,26 +313,4 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None:
|
|||
help="Interactive skill configuration — enable/disable individual skills",
|
||||
)
|
||||
|
||||
# M2 org-shared skills (hsp-1-contract.md §11.5/§11.11): propose a local
|
||||
# skill's content to the org canonical set. MEMBER → 202 proposal
|
||||
# (pending admin review); ADMIN/OWNER → direct merge. Only meaningful for
|
||||
# multi-member orgs — personal orgs have no org workflow (the command
|
||||
# reports that instead of failing opaquely).
|
||||
skills_propose = skills_subparsers.add_parser(
|
||||
"propose",
|
||||
help="Propose a skill to your organisation's shared skill set",
|
||||
description=(
|
||||
"Snapshot the local skill and submit it to the org canonical set. "
|
||||
"An org admin's push merges directly; a member's push becomes a "
|
||||
"proposal reviewed in the org console. Personal orgs keep simple "
|
||||
"personal sync and have no proposal workflow."
|
||||
),
|
||||
)
|
||||
skills_propose.add_argument("name", help="Skill name to propose")
|
||||
skills_propose.add_argument(
|
||||
"-m",
|
||||
"--message",
|
||||
default=None,
|
||||
help="Optional proposal message (defaults to 'propose <name>')",
|
||||
)
|
||||
skills_parser.set_defaults(func=cmd_skills)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
"""``hermes sync`` subcommand parser (personal skill sync).
|
||||
"""``hermes sync`` subcommand parser — Skill Sync.
|
||||
|
||||
Cloned from ``hermes_cli/subcommands/cron.py`` — same injected-handler shape
|
||||
(``func=cmd_sync``) so this module does not import ``main`` (cycle avoidance).
|
||||
|
||||
Commands:
|
||||
hermes sync status -- show gate/opt-in/head state
|
||||
hermes sync pull -- pull the owner's HEAD, materialize opted-in skills
|
||||
hermes sync push -- push opted-in skills to the owner's HEAD
|
||||
hermes sync now -- pull then push (full reconcile)
|
||||
hermes sync enable <skill> -- opt a skill into sync
|
||||
hermes sync disable <skill> -- opt a skill out of sync
|
||||
hermes sync device [--name] -- show or set this device's sync label
|
||||
Skill Sync covers two surfaces, both under this one command for launch:
|
||||
|
||||
This surface is PERSONAL sync only: it moves your own skills between your own
|
||||
devices via ``refs/user/<owner>/HEAD``. Sharing a skill with an organisation
|
||||
is a different operation with a different destination and an approval step —
|
||||
see ``hermes skills propose``.
|
||||
Personal — your own skills, across your own devices:
|
||||
hermes sync status show gate/opt-in/head state
|
||||
hermes sync pull pull and materialize opted-in skills
|
||||
hermes sync push push opted-in skills
|
||||
hermes sync now reconcile: pull then push
|
||||
hermes sync enable <skill> opt a skill into sync
|
||||
hermes sync disable <skill> opt a skill out of sync
|
||||
hermes sync device [--name] show or set this device's label
|
||||
|
||||
Organisation — skills shared with your team:
|
||||
hermes sync propose <skill> share a skill with your organisation
|
||||
|
||||
Sync is INERT unless the resolved Nous token carries the access-gate claim
|
||||
AND a sync base URL is configured. The commands report that state rather than
|
||||
|
|
@ -24,49 +24,48 @@ failing opaquely.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None:
|
||||
"""Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``."""
|
||||
import argparse
|
||||
|
||||
sync_parser = subparsers.add_parser(
|
||||
"sync",
|
||||
help="Personal skill sync across your devices",
|
||||
help="Skill Sync — sync your skills across devices and with your team",
|
||||
description=(
|
||||
"Sync agent-created and user-authored skills across your own "
|
||||
"devices."
|
||||
"Skill Sync keeps your skills with you. Personal sync moves your "
|
||||
"own skills between your devices; if you belong to an "
|
||||
"organisation, you also get its shared skills and can propose "
|
||||
"your own back to the team."
|
||||
),
|
||||
epilog=(
|
||||
"Sharing with your team:\n"
|
||||
" These commands cover your PERSONAL skills only. To share a "
|
||||
"skill with your\n"
|
||||
" organisation, use `hermes skills propose <skill>` instead — it "
|
||||
"submits the\n"
|
||||
" skill to your org's shared set (an admin approves it unless "
|
||||
"you are one).\n"
|
||||
" Approved org skills arrive automatically and are read-only "
|
||||
"locally.\n"
|
||||
"Examples:\n"
|
||||
" hermes sync status what is synced, and from where\n"
|
||||
" hermes sync enable my-skill include a skill in your sync\n"
|
||||
" hermes sync now pull, then push\n"
|
||||
" hermes sync propose my-skill share a skill with your team\n"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
sync_sub = sync_parser.add_subparsers(dest="sync_command")
|
||||
|
||||
sync_sub.add_parser("status", help="Show sync gate, opt-in, and head state")
|
||||
sync_sub.add_parser("pull", help="Pull the owner's HEAD and materialize opted-in skills")
|
||||
sync_sub.add_parser("push", help="Push opted-in skills to the owner's HEAD")
|
||||
sync_sub.add_parser("status", help="Show what is synced, and from where")
|
||||
sync_sub.add_parser(
|
||||
"pull", help="Pull your synced skills (and your organisation's)"
|
||||
)
|
||||
sync_sub.add_parser("push", help="Push your opted-in skills")
|
||||
sync_sub.add_parser("now", help="Reconcile now: pull then push")
|
||||
|
||||
enable = sync_sub.add_parser("enable", help="Opt a skill into sync")
|
||||
enable = sync_sub.add_parser("enable", help="Include a skill in your sync")
|
||||
enable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
|
||||
|
||||
disable = sync_sub.add_parser("disable", help="Opt a skill out of sync")
|
||||
disable = sync_sub.add_parser("disable", help="Exclude a skill from your sync")
|
||||
disable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
|
||||
|
||||
device = sync_sub.add_parser(
|
||||
"device",
|
||||
help="Show or set this device's sync label (shown in the sync console)",
|
||||
help="Show or set this device's label (shown in the sync console)",
|
||||
)
|
||||
device.add_argument(
|
||||
"--name",
|
||||
|
|
@ -76,4 +75,25 @@ def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None:
|
|||
"Omit to print the current label.",
|
||||
)
|
||||
|
||||
# Org-shared skills. A member's submission becomes a proposal an admin
|
||||
# reviews; an admin's merges straight into the shared set. Accounts that
|
||||
# aren't in a shared organisation are told so plainly.
|
||||
propose = sync_sub.add_parser(
|
||||
"propose",
|
||||
help="Share a skill with your organisation",
|
||||
description=(
|
||||
"Submit one of your skills to your organisation's shared set. If "
|
||||
"you are an admin it is added directly; otherwise it becomes a "
|
||||
"proposal for an admin to review. Accounts that aren't part of a "
|
||||
"shared organisation don't have this workflow."
|
||||
),
|
||||
)
|
||||
propose.add_argument("name", help="Skill name to share")
|
||||
propose.add_argument(
|
||||
"-m",
|
||||
"--message",
|
||||
default=None,
|
||||
help="Optional message describing the change",
|
||||
)
|
||||
|
||||
sync_parser.set_defaults(func=cmd_sync)
|
||||
|
|
|
|||
|
|
@ -261,7 +261,9 @@ class TestOrgPullIsWiredIn:
|
|||
root / "hermes_cli" / "subcommands" / "sync.py",
|
||||
root / "hermes_cli" / "subcommands" / "skills.py",
|
||||
]
|
||||
banned = re.compile(r"\(M[12]\)|HSP/1|§[0-9]|DEV-PHASE|hsp-1-contract")
|
||||
banned = re.compile(
|
||||
r"\(M[12]\)|\bHSP\b|HSP/1|§[0-9]|DEV-PHASE|hsp-1-contract"
|
||||
)
|
||||
for path in targets:
|
||||
for i, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1):
|
||||
if "help=" in line or "description=" in line:
|
||||
|
|
@ -270,41 +272,41 @@ class TestOrgPullIsWiredIn:
|
|||
)
|
||||
|
||||
|
||||
class TestOrgSharingIsDiscoverable:
|
||||
"""`hermes sync` must point users at the org-sharing command.
|
||||
class TestSkillSyncIsOneCommand:
|
||||
"""Every Skill Sync verb lives under `hermes sync` for launch.
|
||||
|
||||
Without this, there is no path from "I want to share this with my team"
|
||||
to `hermes skills propose` — sync looks like the only sharing surface
|
||||
while being personal-only.
|
||||
The surface is deliberately encapsulated: one command to learn, one to
|
||||
document, and top-level `sync` stays free of skill-management verbs that
|
||||
belong elsewhere. `propose` in particular used to sit under `hermes
|
||||
skills`, which split one feature across two commands.
|
||||
"""
|
||||
|
||||
def test_sync_usage_block_mentions_propose(self):
|
||||
def _src(self, *parts):
|
||||
import pathlib
|
||||
|
||||
main_src = (
|
||||
pathlib.Path(__file__).resolve().parents[2]
|
||||
/ "hermes_cli"
|
||||
/ "main.py"
|
||||
return (
|
||||
pathlib.Path(__file__).resolve().parents[2].joinpath(*parts)
|
||||
).read_text(encoding="utf-8")
|
||||
usage_start = main_src.index(
|
||||
"usage: hermes sync <status|pull|push|now|enable|disable|device>"
|
||||
)
|
||||
usage_block = main_src[usage_start : usage_start + 1200]
|
||||
assert "hermes skills propose" in usage_block, (
|
||||
"`hermes sync` usage must point at the org-sharing command."
|
||||
|
||||
def test_propose_is_a_sync_subcommand(self):
|
||||
sync_src = self._src("hermes_cli", "subcommands", "sync.py")
|
||||
assert '"propose"' in sync_src, (
|
||||
"`propose` must be a `hermes sync` subcommand."
|
||||
)
|
||||
|
||||
def test_sync_parser_epilog_mentions_propose(self):
|
||||
import pathlib
|
||||
def test_propose_is_not_under_skills(self):
|
||||
skills_src = self._src("hermes_cli", "subcommands", "skills.py")
|
||||
assert '"propose"' not in skills_src, (
|
||||
"`propose` must NOT remain under `hermes skills` — Skill Sync is "
|
||||
"one command for launch."
|
||||
)
|
||||
|
||||
src = (
|
||||
pathlib.Path(__file__).resolve().parents[2]
|
||||
/ "hermes_cli"
|
||||
/ "subcommands"
|
||||
/ "sync.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "hermes skills propose" in src, (
|
||||
"`hermes sync --help` must point at the org-sharing command."
|
||||
def test_sync_usage_lists_propose(self):
|
||||
main_src = self._src("hermes_cli", "main.py")
|
||||
usage_start = main_src.index("usage: hermes sync ")
|
||||
usage_block = main_src[usage_start : usage_start + 1400]
|
||||
assert "propose" in usage_block, (
|
||||
"`hermes sync` usage must list the propose verb."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"""Tests for tools/skills_sync_client.py — the HSP/1 sync client.
|
||||
"""Tests for tools/skills_sync_client.py — the Skill Sync client.
|
||||
|
||||
Covers, against the frozen contract (~/src/specs/collective-wisdom/
|
||||
hsp-1-contract.md):
|
||||
the sync wire contract):
|
||||
* content addressing (full 64-hex) + canonical JSON (§2.1, §2.5)
|
||||
* the DEV-PHASE gate (tool_gateway_admin) making sync inert
|
||||
* the M1-D opt-in default (nothing syncs without the sync flag)
|
||||
* object building (blob/tree/commit, exec mode, size limit)
|
||||
* push (upload + CAS), pull (materialize), and the three-way merge / 409
|
||||
conflict paths — all against an in-process mock HSP server.
|
||||
conflict paths — all against an in-process mock sync server.
|
||||
|
||||
The mock server implements the contract §3/§4 endpoint shapes with an
|
||||
in-memory object store + ref table. No live server, no network.
|
||||
|
|
@ -25,7 +25,7 @@ import tools.skills_sync_client as ssc
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process mock HSP/1 server (contract §3-§4)
|
||||
# In-process mock sync server (read + write endpoints)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _MockState:
|
||||
|
|
@ -237,7 +237,7 @@ def _jwt(claims: dict) -> str:
|
|||
|
||||
class TestAddressing:
|
||||
def test_full_64_hex_address(self):
|
||||
addr = ssc.hsp_address(b"")
|
||||
addr = ssc.wire_address(b"")
|
||||
# sha256 of empty is the well-known e3b0... digest, full 64 hex.
|
||||
assert addr == (
|
||||
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
|
@ -245,9 +245,9 @@ class TestAddressing:
|
|||
assert len(addr.split(":", 1)[1]) == 64
|
||||
|
||||
def test_address_differs_from_local_truncated_namespace(self):
|
||||
# OI-5: HSP full-64-hex must NOT equal the local truncated 16-hex form.
|
||||
# The wire full-64-hex must NOT equal the local truncated 16-hex form.
|
||||
data = b"hello world"
|
||||
full = ssc.hsp_address(data)
|
||||
full = ssc.wire_address(data)
|
||||
truncated = "sha256:" + hashlib.sha256(data).hexdigest()[:16]
|
||||
assert full != truncated
|
||||
assert len(full.split(":")[1]) == 64
|
||||
|
|
@ -460,7 +460,7 @@ def synced_env(tmp_path, monkeypatch):
|
|||
class TestEndToEnd:
|
||||
def test_capabilities_version_check(self, mock_server):
|
||||
base, state = mock_server
|
||||
client = ssc.HSPClient(base, "tok")
|
||||
client = ssc.SyncClient(base, "tok")
|
||||
caps = client.capabilities()
|
||||
assert caps["hsp_version"] == "1"
|
||||
ssc._check_version(caps) # no raise
|
||||
|
|
@ -468,14 +468,14 @@ class TestEndToEnd:
|
|||
def test_version_mismatch_raises(self, mock_server):
|
||||
base, state = mock_server
|
||||
state.hsp_version = "2"
|
||||
client = ssc.HSPClient(base, "tok")
|
||||
with pytest.raises(ssc.HSPError):
|
||||
client = ssc.SyncClient(base, "tok")
|
||||
with pytest.raises(ssc.SyncError):
|
||||
ssc._check_version(client.capabilities())
|
||||
|
||||
def test_push_uploads_and_cas(self, mock_server, synced_env):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
result = ssc.push_skills(client, identity=identity)
|
||||
assert result["ok"] is True
|
||||
# HEAD ref advanced to our commit
|
||||
|
|
@ -491,7 +491,7 @@ class TestEndToEnd:
|
|||
def test_push_then_pull_materializes(self, mock_server, synced_env, tmp_path, monkeypatch):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
ssc.push_skills(client, identity=identity)
|
||||
|
||||
# Simulate a fresh device: new skills dir, same server, same opt-in.
|
||||
|
|
@ -513,7 +513,7 @@ class TestEndToEnd:
|
|||
def test_push_idempotent_reupload(self, mock_server, synced_env):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
r1 = ssc.push_skills(client, identity=identity)
|
||||
n_objects = len(state.objects)
|
||||
# push again with no local change -> same head, objects already_present
|
||||
|
|
@ -525,7 +525,7 @@ class TestEndToEnd:
|
|||
def test_conflict_nonoverlap_merges(self, mock_server, synced_env, monkeypatch):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
# First push establishes a base head we record locally.
|
||||
first = ssc.push_skills(client, identity=identity)
|
||||
# Inject a divergent server head: change beta server-side so the next
|
||||
|
|
@ -543,7 +543,7 @@ class TestEndToEnd:
|
|||
def test_conflict_true_overlap_writes_conflict_ref(self, mock_server, synced_env, monkeypatch):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
ssc.push_skills(client, identity=identity)
|
||||
|
||||
# Build a DIFFERENT server-side head for the SAME skill (alpha) so the
|
||||
|
|
@ -642,7 +642,7 @@ class TestSyncManifest:
|
|||
# plane content. Read it back via read_manifest_of_root.
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
|
||||
objs, root_hash, skill_map = ssc.snapshot_profile(["alpha", "beta"])
|
||||
client.put_objects(objs.objects)
|
||||
|
|
@ -661,7 +661,7 @@ class TestSyncManifest:
|
|||
# becomes opted in locally on pull, even if this device had it disabled.
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
|
||||
# Device A pushes alpha+beta (manifest enables both).
|
||||
ssc.push_skills(client, identity=identity)
|
||||
|
|
@ -857,7 +857,7 @@ class TestOrgEndToEnd:
|
|||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
identity = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
result = ssc.propose_skill("alpha", client, identity=identity)
|
||||
assert result["ok"] is True
|
||||
assert result.get("merged") is True
|
||||
|
|
@ -871,7 +871,7 @@ class TestOrgEndToEnd:
|
|||
home, skills, identity = synced_env
|
||||
# Seed an org HEAD as admin first.
|
||||
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
seeded = ssc.propose_skill("alpha", client, identity=admin_ident)
|
||||
|
||||
# Member edits beta and proposes: server converts to 202.
|
||||
|
|
@ -896,7 +896,7 @@ class TestOrgEndToEnd:
|
|||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
ssc.propose_skill("alpha", client, identity=admin_ident)
|
||||
ssc.propose_skill("beta", client, identity=admin_ident)
|
||||
|
||||
|
|
@ -913,7 +913,7 @@ class TestOrgEndToEnd:
|
|||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
ssc.propose_skill("alpha", client, identity=admin_ident)
|
||||
|
||||
result = ssc.pull_org_skills(client, identity=admin_ident)
|
||||
|
|
@ -927,7 +927,7 @@ class TestOrgEndToEnd:
|
|||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
result = ssc.pull_org_skills(client, identity=ident)
|
||||
assert result["ok"] is True
|
||||
assert result["head"] is None
|
||||
|
|
@ -938,7 +938,7 @@ class TestOrgEndToEnd:
|
|||
home, skills, identity = synced_env
|
||||
state.org_feature = False
|
||||
ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
with pytest.raises(ssc.SyncInertError):
|
||||
ssc.propose_skill("alpha", client, identity=ident)
|
||||
|
||||
|
|
|
|||
|
|
@ -639,7 +639,7 @@ def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]:
|
|||
return (
|
||||
f"This skill is shared by your organisation. Your edit is "
|
||||
f"saved locally and will not be overwritten by org updates. "
|
||||
f"Run `hermes skills propose {name}` to share it back."
|
||||
f"Run `hermes sync propose {name}` to share it back."
|
||||
)
|
||||
result = ssc.propose_skill(name)
|
||||
if result.get("proposal_pending"):
|
||||
|
|
@ -652,7 +652,7 @@ def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]:
|
|||
logger.debug("auto-propose skipped for %s: %s", name, e)
|
||||
return (
|
||||
f"Edit saved locally. Could not submit it to your organisation "
|
||||
f"right now — run `hermes skills propose {name}` to retry."
|
||||
f"right now — run `hermes sync propose {name}` to retry."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -668,7 +668,7 @@ def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optiona
|
|||
|
||||
Now an edit lands in the mirror and is protected from being overwritten by
|
||||
the next org pull (see the baseline sidecar in skills_sync_client). It
|
||||
reaches the organisation when the user runs `hermes skills propose`, or
|
||||
reaches the organisation when the user runs `hermes sync propose`, or
|
||||
immediately if `sync.org_auto_propose` is on.
|
||||
|
||||
Deletion is still refused: the mirror is a materialized view of the org
|
||||
|
|
@ -688,7 +688,7 @@ def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optiona
|
|||
"organisation, so a local delete would just come back on "
|
||||
"the next sync. Ask an org admin to remove it for "
|
||||
"everyone. (Editing it IS allowed — your changes are kept "
|
||||
"and can be proposed back with `hermes skills propose "
|
||||
"and can be proposed back with `hermes sync propose "
|
||||
f"{name}`.)"
|
||||
),
|
||||
}
|
||||
|
|
@ -1438,7 +1438,7 @@ def apply_skill_pending(payload: Dict[str, Any]) -> str:
|
|||
_skill_gate_bypass.reset(token)
|
||||
|
||||
|
||||
# Debounce state for the HSP sync push hook. A burst of skill_manage writes
|
||||
# Debounce state for the sync push hook. A burst of skill_manage writes
|
||||
# (e.g. create + several write_file calls) collapses into a single push after
|
||||
# a short quiet window, on a daemon timer so the agent write never blocks.
|
||||
_sync_push_timer = None
|
||||
|
|
@ -1447,7 +1447,7 @@ _SYNC_PUSH_DEBOUNCE_S = 5.0
|
|||
|
||||
|
||||
def _maybe_debounced_sync_push(skill_name: str) -> None:
|
||||
"""Schedule a debounced best-effort HSP push after a skill write.
|
||||
"""Schedule a debounced best-effort sync push after a skill write.
|
||||
|
||||
Cheap fast-path: if the skill isn't opted into sync, do nothing (no auth,
|
||||
no network). Otherwise (re)arm a daemon timer; the actual push runs through
|
||||
|
|
@ -1586,7 +1586,7 @@ def skill_manage(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# HSP sync push hook (debounced, best-effort). Fires only AFTER the
|
||||
# Sync push hook (debounced, best-effort). Fires only AFTER the
|
||||
# write gate passed (staged/unapproved writes never reach here -- the
|
||||
# gate returns early above), so we never push un-reviewed content.
|
||||
# Inert unless the DEV-PHASE gate is open (tool_gateway_admin on the
|
||||
|
|
|
|||
|
|
@ -680,7 +680,7 @@ def set_pinned(skill_name: str, pinned: bool) -> None:
|
|||
|
||||
|
||||
def set_sync(skill_name: str, sync: bool) -> None:
|
||||
"""Set the HSP-sync opt-in flag on a skill's usage record (M1-D).
|
||||
"""Set the sync opt-in flag on a skill's usage record.
|
||||
|
||||
Sync is OPT-IN: nothing propagates to the sync plane unless the user marks
|
||||
a skill with ``sync: true`` here. Sits alongside ``pinned``/``created_by``
|
||||
|
|
@ -695,7 +695,7 @@ def set_sync(skill_name: str, sync: bool) -> None:
|
|||
|
||||
|
||||
def is_sync_enabled(skill_name: str) -> bool:
|
||||
"""Whether a skill is opted into HSP sync (``sync: true`` in its record)."""
|
||||
"""Whether a skill is opted into sync (``sync: true`` in its record)."""
|
||||
return get_record(skill_name).get("sync") is True
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
HSP/1 sync client -- Hermes Sync Protocol version 1, client (personal skill sync).
|
||||
Skill Sync client -- the low-level sync layer.
|
||||
|
||||
This is the LOW-LEVEL sync layer. It builds content-addressed HSP objects
|
||||
(blob/tree/commit) from local skills, talks the HSP/1 wire contract to a sync
|
||||
This is the LOW-LEVEL sync layer. It builds content-addressed objects
|
||||
(blob/tree/commit) from local skills, talks the sync wire contract to a sync
|
||||
plane (push objects + CAS a ref, pull the owner's HEAD, three-way merge on a
|
||||
409), and is driven by:
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ It lives beside ``tools/skills_sync.py`` (NOT under ``hermes_cli/``) so the
|
|||
low-level sync layer never imports the CLI -- same rule the bundled-skills
|
||||
sync module documents at ``skills_sync.py:43-50``.
|
||||
|
||||
Contract: ``~/src/specs/collective-wisdom/hsp-1-contract.md`` (HSP/1, frozen
|
||||
Contract: the Skill Sync wire contract (version 1, frozen
|
||||
for Milestone 1). Endpoint shapes, object model, canonicalization, and status
|
||||
codes below all trace to that document.
|
||||
|
||||
|
|
@ -58,7 +58,11 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sync protocol constants
|
||||
HSP_VERSION = "1"
|
||||
# Wire protocol version. The over-the-wire names below (the `hsp_version`
|
||||
# capability field and the `x-hsp-object-type` response header) are part of
|
||||
# the deployed server contract and are NOT renamed with the product — the
|
||||
# user-facing feature is "Skill Sync"; these are protocol identifiers.
|
||||
WIRE_VERSION = "1"
|
||||
DEFAULT_MAX_OBJECT_BYTES = 26214400 # 25 MiB, mirrors capabilities default
|
||||
|
||||
# Object kinds (sync contract)
|
||||
|
|
@ -77,7 +81,7 @@ ARTIFACT_TYPE_SKILL = "skill"
|
|||
# `sync-manifest` object convention (design notes).
|
||||
#
|
||||
# Per-skill sync opt-in ("this skill syncs / this one does not"
|
||||
# opt-in state) is CONTENT inside the HSP object model, NOT a device-local flag
|
||||
# opt-in state) is CONTENT inside the sync object model, NOT a device-local flag
|
||||
# or a mutable preference table. An owner's synced set is a small committed blob
|
||||
# named ``sync-manifest`` at the ROOT of the tree referenced by
|
||||
# ``refs/user/<owner>/HEAD``, recording per-skill ``{name, enabled}``. Toggling
|
||||
|
|
@ -158,14 +162,14 @@ def parse_sync_manifest(data: bytes) -> Optional[Dict[str, bool]]:
|
|||
# ---------------------------------------------------------------------------
|
||||
# Content addressing
|
||||
#
|
||||
# HSP uses the FULL 64-hex sha256 digest on the wire. This is a DIFFERENT
|
||||
# The wire uses the FULL 64-hex sha256 digest. This is a DIFFERENT
|
||||
# namespace from hermes-agent's local ``content_hash`` (skills_guard.py:846),
|
||||
# which is a truncated 16-hex digest used for local dedup. They must never be
|
||||
# conflated -- we compute full digests here.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def hsp_address(data: bytes) -> str:
|
||||
"""Return ``sha256:<64-hex>`` -- the HSP wire address of ``data`` (sync contract)."""
|
||||
def wire_address(data: bytes) -> str:
|
||||
"""Return ``sha256:<64-hex>`` -- the wire address of ``data``."""
|
||||
return "sha256:" + hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
|
|
@ -279,14 +283,14 @@ def dev_gate_open() -> bool:
|
|||
# ---------------------------------------------------------------------------
|
||||
# Sync-plane endpoint resolution
|
||||
#
|
||||
# The HSP routes are mounted under /v1/sync/ (sync contract). The base URL is
|
||||
# The sync routes are mounted under /v1/sync/. The base URL is
|
||||
# configurable (config.yaml sync.base_url or HERMES_SYNC_BASE_URL bridge env);
|
||||
# it is NOT the inference base_url. When unset, sync is inert -- there is no
|
||||
# server to talk to yet (the server is being built in parallel).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_sync_base_url() -> Optional[str]:
|
||||
"""Resolve the HSP sync-plane base URL, or None when unconfigured.
|
||||
"""Resolve the sync-plane base URL, or None when unconfigured.
|
||||
|
||||
Order: HERMES_SYNC_BASE_URL env bridge -> config.yaml ``sync.base_url``.
|
||||
Returns a base without a trailing slash (e.g. ``https://host``); the
|
||||
|
|
@ -318,7 +322,7 @@ def resolve_sync_base_url() -> Optional[str]:
|
|||
# precedence as base_url: the HERMES_SYNC_* env var wins, else config.yaml
|
||||
# ``sync.*``, else a built-in default.
|
||||
#
|
||||
# HERMES_SYNC_BASE_URL -> sync.base_url (the HSP plane URL)
|
||||
# HERMES_SYNC_BASE_URL -> sync.base_url (the sync plane URL)
|
||||
# HERMES_SYNC_ENABLED -> sync.enabled (master on/off; default off)
|
||||
# HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (personal sync policy; default false
|
||||
# = opt-in. Set true to make
|
||||
|
|
@ -383,7 +387,7 @@ def sync_org_auto_propose() -> bool:
|
|||
``HERMES_SYNC_ORG_AUTO_PROPOSE`` -> ``sync.org_auto_propose`` -> False.
|
||||
|
||||
False (default): edits to an org-shared skill stay LOCAL until the user
|
||||
runs ``hermes skills propose <skill>``. The skill keeps working with the
|
||||
runs ``hermes sync propose <skill>``. The skill keeps working with the
|
||||
edit applied; the organisation just doesn't see it yet.
|
||||
|
||||
True: every local edit to an org skill is submitted to the org as a
|
||||
|
|
@ -427,7 +431,7 @@ def _skills_dir() -> Path:
|
|||
|
||||
|
||||
def is_sync_eligible(skill_name: str) -> bool:
|
||||
"""Whether *skill_name* is a candidate for HSP sync (before the opt-in check).
|
||||
"""Whether *skill_name* is a candidate for sync (before the opt-in check).
|
||||
|
||||
Eligible = present locally under ~/.hermes/skills/, NOT bundled, NOT
|
||||
hub-installed, NOT an external-dir skill, and NOT under the org mirror
|
||||
|
|
@ -525,7 +529,7 @@ def _all_local_skill_names() -> List[str]:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Object building -- turn a skill directory into HSP blob/tree/commit objects
|
||||
# Object building -- turn a skill directory into blob/tree/commit objects
|
||||
#
|
||||
# A skill dir becomes one tree (sync contract). Each file is a blob; each
|
||||
# subdir a nested tree. The profile-root tree (the sync contract: "a tree whose
|
||||
|
|
@ -533,7 +537,7 @@ def _all_local_skill_names() -> List[str]:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ObjectSet:
|
||||
"""Accumulates HSP objects to push: hash -> (kind, bytes).
|
||||
"""Accumulates objects to push: hash -> (kind, bytes).
|
||||
|
||||
Deduped by content address, so identical blobs across skills upload once.
|
||||
"""
|
||||
|
|
@ -542,7 +546,7 @@ class ObjectSet:
|
|||
self.objects: Dict[str, Tuple[str, bytes]] = {}
|
||||
|
||||
def add(self, kind: str, data: bytes) -> str:
|
||||
addr = hsp_address(data)
|
||||
addr = wire_address(data)
|
||||
self.objects.setdefault(addr, (kind, data))
|
||||
return addr
|
||||
|
||||
|
|
@ -551,7 +555,7 @@ class ObjectSet:
|
|||
|
||||
|
||||
def _file_mode(path: Path) -> str:
|
||||
"""Return the HSP tree mode for a regular file: ``exec`` if +x else ``file``
|
||||
"""Return the tree mode for a regular file: ``exec`` if +x else ``file``
|
||||
(contract §2.3). No symlinks / other modes are emitted."""
|
||||
try:
|
||||
if path.stat().st_mode & (_stat.S_IXUSR | _stat.S_IXGRP | _stat.S_IXOTH):
|
||||
|
|
@ -562,7 +566,7 @@ def _file_mode(path: Path) -> str:
|
|||
|
||||
|
||||
def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) -> str:
|
||||
"""Recursively build HSP objects for *dir_path*; return the tree address.
|
||||
"""Recursively build objects for *dir_path*; return the tree address.
|
||||
|
||||
Regular files become blobs; subdirectories become nested trees. Symlinks,
|
||||
sockets, and other special files are skipped (contract §2.3 security: no
|
||||
|
|
@ -705,22 +709,22 @@ def set_device_name(name: str) -> str:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HSP/1 wire client
|
||||
# Sync wire client
|
||||
#
|
||||
# Thin requests-based client for the endpoints in the sync contract- Uploads all
|
||||
# new objects (batch), then CAS-es the ref. A 409 returns the actual head for
|
||||
# the caller's three-way merge. Auth is the Nous bearer resolved above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HSPError(RuntimeError):
|
||||
"""A non-recoverable HSP wire error (4xx that the client can't retry)."""
|
||||
class SyncError(RuntimeError):
|
||||
"""A non-recoverable wire error (4xx that the client can't retry)."""
|
||||
|
||||
def __init__(self, message: str, *, status: Optional[int] = None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class HSPConflict(RuntimeError):
|
||||
class SyncConflict(RuntimeError):
|
||||
"""CAS lost (409). ``actual`` is the current head to merge against
|
||||
(contract §4.4). NOT a rejection -- pushed objects are already durable."""
|
||||
|
||||
|
|
@ -729,7 +733,7 @@ class HSPConflict(RuntimeError):
|
|||
self.actual = actual
|
||||
|
||||
|
||||
class HSPClient:
|
||||
class SyncClient:
|
||||
"""Sync client bound to a base URL + bearer (routes under
|
||||
``/v1/sync/``)."""
|
||||
|
||||
|
|
@ -751,7 +755,7 @@ class HSPClient:
|
|||
"""GET /v1/sync/capabilities (sync contract). No auth required."""
|
||||
r = self._session.get(self._url("capabilities"), timeout=self.timeout)
|
||||
if r.status_code != 200:
|
||||
raise HSPError(f"capabilities failed: {r.status_code}", status=r.status_code)
|
||||
raise SyncError(f"capabilities failed: {r.status_code}", status=r.status_code)
|
||||
return r.json()
|
||||
|
||||
def get_refs(self, prefix: str) -> List[Dict[str, str]]:
|
||||
|
|
@ -760,22 +764,22 @@ class HSPClient:
|
|||
self._url("refs"), params={"prefix": prefix}, timeout=self.timeout
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise HSPError(f"get_refs failed: {r.status_code}", status=r.status_code)
|
||||
raise SyncError(f"get_refs failed: {r.status_code}", status=r.status_code)
|
||||
return (r.json() or {}).get("refs", [])
|
||||
|
||||
def get_object(self, obj_hash: str) -> Tuple[str, bytes]:
|
||||
"""GET /v1/sync/objects/:hash (sync contract). Returns (kind, bytes).
|
||||
|
||||
Kind comes from ``X-HSP-Object-Type`` for tree/commit; a blob response
|
||||
Kind comes from the object-type response header for tree/commit; a blob
|
||||
(application/octet-stream) is returned as ``blob``.
|
||||
"""
|
||||
r = self._session.get(self._url(f"objects/{obj_hash}"), timeout=self.timeout)
|
||||
if r.status_code == 404:
|
||||
raise HSPError(f"object {obj_hash} not found", status=404)
|
||||
raise SyncError(f"object {obj_hash} not found", status=404)
|
||||
if r.status_code == 403:
|
||||
raise HSPError(f"object {obj_hash} not readable", status=403)
|
||||
raise SyncError(f"object {obj_hash} not readable", status=403)
|
||||
if r.status_code != 200:
|
||||
raise HSPError(f"get_object failed: {r.status_code}", status=r.status_code)
|
||||
raise SyncError(f"get_object failed: {r.status_code}", status=r.status_code)
|
||||
kind = r.headers.get("X-HSP-Object-Type") or KIND_BLOB
|
||||
return kind, r.content
|
||||
|
||||
|
|
@ -783,14 +787,14 @@ class HSPClient:
|
|||
"""Fetch a commit object and parse its canonical JSON."""
|
||||
kind, data = self.get_object(commit_hash)
|
||||
if kind != KIND_COMMIT:
|
||||
raise HSPError(f"{commit_hash} is {kind}, expected commit")
|
||||
raise SyncError(f"{commit_hash} is {kind}, expected commit")
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
def get_tree_json(self, tree_hash: str) -> Dict[str, Any]:
|
||||
"""Fetch a tree object and parse its canonical JSON."""
|
||||
kind, data = self.get_object(tree_hash)
|
||||
if kind != KIND_TREE:
|
||||
raise HSPError(f"{tree_hash} is {kind}, expected tree")
|
||||
raise SyncError(f"{tree_hash} is {kind}, expected tree")
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
# -- write -------------------------------------------------------------
|
||||
|
|
@ -833,17 +837,17 @@ class HSPClient:
|
|||
timeout=self.timeout,
|
||||
)
|
||||
if r.status_code == 413:
|
||||
raise HSPError("object too large (413)", status=413)
|
||||
raise SyncError("object too large (413)", status=413)
|
||||
if r.status_code == 422:
|
||||
raise HSPError(f"hash_mismatch (422): {r.text}", status=422)
|
||||
raise SyncError(f"hash_mismatch (422): {r.text}", status=422)
|
||||
if r.status_code not in (200, 201):
|
||||
raise HSPError(f"put_objects failed: {r.status_code}", status=r.status_code)
|
||||
raise SyncError(f"put_objects failed: {r.status_code}", status=r.status_code)
|
||||
return r.json() if r.content else {}
|
||||
|
||||
def cas_ref(self, name: str, from_hash: Optional[str], to_hash: str) -> Dict[str, Any]:
|
||||
"""POST /v1/sync/refs/:name -- atomic compare-and-swap (sync contract).
|
||||
|
||||
Raises :class:`HSPConflict` (carrying the actual head) on 409.
|
||||
Raises :class:`SyncConflict` (carrying the actual head) on 409.
|
||||
|
||||
M2 (contract §11.5): a non-admin member's CAS on an org HEAD is never
|
||||
rejected — the server converts it to a proposal and returns
|
||||
|
|
@ -862,16 +866,16 @@ class HSPClient:
|
|||
return {"proposal_pending": True, **body}
|
||||
if r.status_code == 409:
|
||||
actual = (r.json() or {}).get("actual", "")
|
||||
raise HSPConflict(actual)
|
||||
raise SyncConflict(actual)
|
||||
if r.status_code == 403:
|
||||
raise HSPError("forbidden (403) -- owner/permission", status=403)
|
||||
raise SyncError("forbidden (403) -- owner/permission", status=403)
|
||||
if r.status_code != 200:
|
||||
raise HSPError(f"cas_ref failed: {r.status_code}", status=r.status_code)
|
||||
raise SyncError(f"cas_ref failed: {r.status_code}", status=r.status_code)
|
||||
return r.json() if r.content else {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HSP local sync STATE (client-local head bookkeeping, FULL-digest namespace)
|
||||
# Local sync STATE (client-local head bookkeeping, FULL-digest namespace)
|
||||
#
|
||||
# Records the last commit HEAD we pushed/pulled and, per synced skill, the tree
|
||||
# hash of the on-disk content at that point. Distinct from the bundled manifest
|
||||
|
|
@ -894,7 +898,7 @@ def _legacy_sync_state_path() -> Path:
|
|||
|
||||
|
||||
def read_sync_state() -> Dict[str, Any]:
|
||||
"""Read the local HSP sync state. Returns a default on missing/corrupt.
|
||||
"""Read the local sync state. Returns a default on missing/corrupt.
|
||||
|
||||
Shape: ``{"head": "sha256:...|null", "skills": {name: {tree, commit}}}``.
|
||||
``head`` is the last profile-root HEAD commit we reconciled with.
|
||||
|
|
@ -933,7 +937,7 @@ def read_sync_state() -> Dict[str, Any]:
|
|||
|
||||
|
||||
def write_sync_state(data: Dict[str, Any]) -> None:
|
||||
"""Write the local HSP sync state atomically. Best-effort."""
|
||||
"""Write the local sync state atomically. Best-effort."""
|
||||
import tempfile
|
||||
|
||||
path = _sync_state_path()
|
||||
|
|
@ -957,11 +961,11 @@ def write_sync_state(data: Dict[str, Any]) -> None:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tree materialization (pull) -- write an HSP tree back to a skill directory
|
||||
# Tree materialization (pull) -- write a tree back to a skill directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def materialize_tree(client: HSPClient, tree_hash: str, dest: Path) -> None:
|
||||
"""Write the HSP tree at *tree_hash* into *dest* (created if needed).
|
||||
def materialize_tree(client: SyncClient, tree_hash: str, dest: Path) -> None:
|
||||
"""Write the tree at *tree_hash* into *dest* (created if needed).
|
||||
|
||||
Blobs become files (with +x restored for ``exec`` mode), nested trees
|
||||
become subdirectories. Does NOT delete files absent from the tree -- the
|
||||
|
|
@ -1017,7 +1021,7 @@ def _skill_rel_path(skill_name: str) -> Optional[PurePosixPath]:
|
|||
def snapshot_profile(
|
||||
skill_names: List[str], *, max_object_bytes: int = DEFAULT_MAX_OBJECT_BYTES
|
||||
) -> Tuple[ObjectSet, str, Dict[str, str]]:
|
||||
"""Build all HSP objects for *skill_names* + the profile-root tree.
|
||||
"""Build all objects for *skill_names* + the profile-root tree.
|
||||
|
||||
Returns ``(objects, root_tree_hash, skill_tree_map)`` where
|
||||
``skill_tree_map`` is ``{skill_name: tree_hash}``. Skills whose blobs
|
||||
|
|
@ -1074,7 +1078,7 @@ def snapshot_profile(
|
|||
def _build_root_tree(
|
||||
node: Dict[str, Any], objects: ObjectSet, *, manifest_hash: Optional[str] = None
|
||||
) -> str:
|
||||
"""Recursively canonicalize the nested root structure into HSP trees.
|
||||
"""Recursively canonicalize the nested root structure into trees.
|
||||
|
||||
``manifest_hash`` (only passed at the top level) adds a root-level
|
||||
``sync-manifest`` BLOB entry (design.md §2.8) alongside the skill subtrees.
|
||||
|
|
@ -1117,12 +1121,12 @@ def user_conflict_ref(owner: str, n: int) -> str:
|
|||
return f"refs/user/{owner}/conflict/{n}"
|
||||
|
||||
|
||||
def _root_tree_of_commit(client: "HSPClient", commit_hash: str) -> str:
|
||||
def _root_tree_of_commit(client: "SyncClient", commit_hash: str) -> str:
|
||||
"""Return the tree hash referenced by a commit."""
|
||||
return client.get_commit_json(commit_hash)["tree"]
|
||||
|
||||
|
||||
def _skill_trees_of_root(client: "HSPClient", root_tree_hash: str) -> Dict[str, str]:
|
||||
def _skill_trees_of_root(client: "SyncClient", root_tree_hash: str) -> Dict[str, str]:
|
||||
"""Flatten a profile-root tree into ``{posix_rel_path: skill_tree_hash}``.
|
||||
|
||||
A skill tree is any tree containing a ``SKILL.md`` blob entry. We walk the
|
||||
|
|
@ -1150,7 +1154,7 @@ def _skill_trees_of_root(client: "HSPClient", root_tree_hash: str) -> Dict[str,
|
|||
|
||||
|
||||
def read_manifest_of_root(
|
||||
client: "HSPClient", root_tree_hash: str
|
||||
client: "SyncClient", root_tree_hash: str
|
||||
) -> Optional[Dict[str, bool]]:
|
||||
"""Read the ``sync-manifest`` blob at the root of *root_tree_hash* into
|
||||
``{name: enabled}`` (design.md §2.8), or ``None`` if there is no manifest
|
||||
|
|
@ -1178,10 +1182,13 @@ def read_manifest_of_root(
|
|||
|
||||
def _check_version(caps: Dict[str, Any]) -> None:
|
||||
"""Reject an incompatible server major version (sync contract)."""
|
||||
ver = str(caps.get("hsp_version") or "")
|
||||
ver = str(caps.get("hsp_version") or "") # wire field name
|
||||
major = ver.split(".", 1)[0]
|
||||
if major != HSP_VERSION:
|
||||
raise HSPError(f"incompatible HSP version {ver!r} (client speaks {HSP_VERSION})")
|
||||
if major != WIRE_VERSION:
|
||||
raise SyncError(
|
||||
f"this server speaks sync version {ver!r}, but this Hermes speaks "
|
||||
f"{WIRE_VERSION} — update Hermes to sync with it"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1189,7 +1196,7 @@ def _check_version(caps: Dict[str, Any]) -> None:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def push_skills(
|
||||
client: Optional["HSPClient"] = None,
|
||||
client: Optional["SyncClient"] = None,
|
||||
*,
|
||||
skill_names: Optional[List[str]] = None,
|
||||
identity: Optional[Dict[str, Any]] = None,
|
||||
|
|
@ -1208,7 +1215,7 @@ def push_skills(
|
|||
base = resolve_sync_base_url()
|
||||
if not base:
|
||||
return {"ok": False, "reason": "no sync base url configured", "noop": True}
|
||||
client = HSPClient(base, identity["api_key"])
|
||||
client = SyncClient(base, identity["api_key"])
|
||||
|
||||
if skill_names is None:
|
||||
skill_names = list_synced_skill_names()
|
||||
|
|
@ -1245,7 +1252,7 @@ def push_skills(
|
|||
manifest["root"] = root_hash
|
||||
write_sync_state(manifest)
|
||||
return {"ok": True, "head": commit_hash, "pushed_objects": len(objects)}
|
||||
except HSPConflict as conflict:
|
||||
except SyncConflict as conflict:
|
||||
return _resolve_push_conflict(
|
||||
client, identity, conflict.actual, root_hash, commit_hash,
|
||||
objects, skill_names, message, base_head,
|
||||
|
|
@ -1273,7 +1280,7 @@ def push_skills(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_push_conflict(
|
||||
client: "HSPClient",
|
||||
client: "SyncClient",
|
||||
identity: Dict[str, Any],
|
||||
actual_head: str,
|
||||
our_root: str,
|
||||
|
|
@ -1321,7 +1328,7 @@ def _resolve_push_conflict(
|
|||
conflict_ref = user_conflict_ref(owner, n)
|
||||
try:
|
||||
client.cas_ref(conflict_ref, None, our_commit)
|
||||
except HSPConflict:
|
||||
except SyncConflict:
|
||||
pass # someone else grabbed this index; the head still exists
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -1353,7 +1360,7 @@ def _resolve_push_conflict(
|
|||
client.put_objects(merge_objects.objects)
|
||||
try:
|
||||
client.cas_ref(user_head_ref(owner), actual_head, merge_commit)
|
||||
except HSPConflict as c2:
|
||||
except SyncConflict as c2:
|
||||
return {
|
||||
"ok": False,
|
||||
"conflict": True,
|
||||
|
|
@ -1388,7 +1395,7 @@ def _merge_skill(base: Optional[str], ours: Optional[str], theirs: Optional[str]
|
|||
|
||||
|
||||
def _assemble_root_from_skill_trees(
|
||||
client: "HSPClient", skill_trees: Dict[str, str], objects: "ObjectSet"
|
||||
client: "SyncClient", skill_trees: Dict[str, str], objects: "ObjectSet"
|
||||
) -> str:
|
||||
"""Build a profile-root tree object from ``{posix_rel_path: tree_hash}``.
|
||||
|
||||
|
|
@ -1406,11 +1413,11 @@ def _assemble_root_from_skill_trees(
|
|||
return _build_root_tree(root, objects)
|
||||
|
||||
|
||||
def _next_conflict_index(client: "HSPClient", owner: str) -> int:
|
||||
def _next_conflict_index(client: "SyncClient", owner: str) -> int:
|
||||
"""Pick the next free conflict ref index for the owner."""
|
||||
try:
|
||||
refs = client.get_refs(f"refs/user/{owner}/conflict/")
|
||||
except HSPError:
|
||||
except SyncError:
|
||||
return 1
|
||||
used = []
|
||||
for r in refs:
|
||||
|
|
@ -1426,7 +1433,7 @@ def _next_conflict_index(client: "HSPClient", owner: str) -> int:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pull_skills(
|
||||
client: Optional["HSPClient"] = None,
|
||||
client: Optional["SyncClient"] = None,
|
||||
*,
|
||||
identity: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
|
|
@ -1445,7 +1452,7 @@ def pull_skills(
|
|||
base = resolve_sync_base_url()
|
||||
if not base:
|
||||
return {"ok": False, "reason": "no sync base url configured", "noop": True}
|
||||
client = HSPClient(base, identity["api_key"])
|
||||
client = SyncClient(base, identity["api_key"])
|
||||
|
||||
caps = client.capabilities()
|
||||
_check_version(caps)
|
||||
|
|
@ -1652,7 +1659,7 @@ def list_org_skill_names() -> List[str]:
|
|||
# here is inert (org_sync_available() False; pull/propose raise SyncInertError)
|
||||
# and the personal personal sync experience is untouched.
|
||||
#
|
||||
# TRAJECTORY (Ben): `hermes skills propose` is the org sharing MVP surface; proposal is
|
||||
# `hermes sync propose` is the org sharing surface; proposal is
|
||||
# intended to become largely automated later (curator/background hooks driving
|
||||
# the same propose_skill() path). Keep this callable non-interactive.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1702,7 +1709,7 @@ def _org_dir() -> Path:
|
|||
|
||||
|
||||
def pull_org_skills(
|
||||
client: Optional["HSPClient"] = None,
|
||||
client: Optional["SyncClient"] = None,
|
||||
*,
|
||||
identity: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
|
|
@ -1722,7 +1729,7 @@ def pull_org_skills(
|
|||
base_url = resolve_sync_base_url()
|
||||
if not base_url:
|
||||
raise SyncInertError("no sync base URL configured")
|
||||
client = HSPClient(base_url, identity["api_key"])
|
||||
client = SyncClient(base_url, identity["api_key"])
|
||||
|
||||
caps = client.capabilities()
|
||||
_check_version(caps)
|
||||
|
|
@ -1916,7 +1923,7 @@ def _write_org_provenance(org_id: str, data: Dict[str, Any]) -> None:
|
|||
|
||||
def propose_skill(
|
||||
skill_name: str,
|
||||
client: Optional["HSPClient"] = None,
|
||||
client: Optional["SyncClient"] = None,
|
||||
*,
|
||||
identity: Optional[Dict[str, Any]] = None,
|
||||
message: Optional[str] = None,
|
||||
|
|
@ -1942,7 +1949,7 @@ def propose_skill(
|
|||
base_url = resolve_sync_base_url()
|
||||
if not base_url:
|
||||
raise SyncInertError("no sync base URL configured")
|
||||
client = HSPClient(base_url, identity["api_key"])
|
||||
client = SyncClient(base_url, identity["api_key"])
|
||||
|
||||
caps = client.capabilities()
|
||||
_check_version(caps)
|
||||
|
|
@ -1953,10 +1960,10 @@ def propose_skill(
|
|||
# Locate the local skill directory (personal namespace, NOT _org/).
|
||||
rel = _skill_rel_path(skill_name)
|
||||
if rel is None:
|
||||
raise HSPError(f"skill '{skill_name}' not found under the skills dir")
|
||||
raise SyncError(f"skill '{skill_name}' not found under the skills dir")
|
||||
skill_dir = _skills_dir() / rel
|
||||
if not (skill_dir / "SKILL.md").exists():
|
||||
raise HSPError(f"skill '{skill_name}' has no SKILL.md")
|
||||
raise SyncError(f"skill '{skill_name}' has no SKILL.md")
|
||||
|
||||
# Build the proposed skill tree.
|
||||
objects = ObjectSet()
|
||||
|
|
|
|||
|
|
@ -1617,7 +1617,7 @@ def skill_view(
|
|||
"Your edits are kept locally\n"
|
||||
"> and are never overwritten by org updates; share "
|
||||
"them back with\n"
|
||||
"> `hermes skills propose` (or automatically, if your "
|
||||
"> `hermes sync propose` (or automatically, if your "
|
||||
"org enables it).\n\n"
|
||||
)
|
||||
rendered_content = header + rendered_content
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue