Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 95 additions & 12 deletions hermes_cli/profile_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import fnmatch
import operator
import os
import re
Expand Down Expand Up @@ -55,6 +56,32 @@
"local",
})

# Runtime state the installing profile's own Hermes writes inside distribution-owned dirs. It is
# never authored content: shipping it would swap the installer's live copy (a lock a running
# gateway holds, a WAL-mode ledger, the curator's schedule) for the author's and publish the
# author's run history. Globs match the entry directly under the owned dir.
_RUNTIME_STATE: Dict[str, Tuple[str, ...]] = {
"cron": (
"*.lock", # .jobs.lock, .tick.lock, .fire-*.lock
"ticker_*", "catch_up_occurrences", # ticker liveness read by `hermes cron status`
"*.db", "*.db-wal", "*.db-shm", # executions ledger, delivery queue, notepad
"*.jsonl", "suggestions.json", # scheduler audit and telemetry, job suggestions
"output", "external-workers", "bot_chat_pending", # run output, in-flight handoffs
),
"skills": (
".hub", ".usage.json", ".bundled_manifest", # hub installs, usage counters, bundled-sync hashes
".curator_*", ".archive", ".locks", # curator schedule, ledger, backups, archive, locks
),
}

# Cron job fields an author sets (``cron.jobs.create_job`` arguments). The rest of a record is
# the installing profile's own state: pause flags, next/last run, failure streak, claims.
_CRON_JOB_DEFINITION: Tuple[str, ...] = (
"name", "prompt", "skills", "skill", "model", "provider", "base_url", "script", "no_agent",
"monitor_script", "monitor_url", "context_from", "schedule", "schedule_display", "deliver",
"origin", "enabled_toolsets", "workdir", "attach_to_session", "reasoning_effort", "failure_deliver",
)


class DistributionError(Exception):
"""Raised for distribution install/update failures."""
Expand Down Expand Up @@ -336,6 +363,11 @@ def plan_install(source: str, workdir: Path, override_name: Optional[str] = None
)


def _is_runtime_state(rel_parts: Tuple[str, ...]) -> bool:
patterns = _RUNTIME_STATE.get(rel_parts[0], ()) if len(rel_parts) > 1 else ()
return any(fnmatch.fnmatchcase(rel_parts[1], pattern) for pattern in patterns)


def _owned_entries(staged: Path, manifest: DistributionManifest):
"""Yield ``(src, rel_parts)`` for every staged path the distribution owns."""
explicit_owned = [p for p in (p.strip().strip("/") for p in manifest.distribution_owned) if p]
Expand All @@ -350,7 +382,7 @@ def _owned_entries(staged: Path, manifest: DistributionManifest):
# Path-aware allowlist: copy exactly the declared paths.
for rel in explicit_owned:
rel_parts = PurePosixPath(rel).parts
if not rel_parts or rel_parts[0] in USER_OWNED_EXCLUDE:
if not rel_parts or rel_parts[0] in USER_OWNED_EXCLUDE or _is_runtime_state(rel_parts):
continue
if ".." in rel_parts or PurePosixPath(rel).is_absolute():
continue
Expand Down Expand Up @@ -378,6 +410,53 @@ def _replace_entry(src: Path, dest: Path) -> None:
shutil.copy2(src, dest)


def _with_job_definition(record: Dict[str, Any], shipped: Dict[str, Any]) -> Dict[str, Any]:
"""*record* carrying the author's definition from *shipped*, its own state untouched."""
merged = {k: v for k, v in record.items() if k not in _CRON_JOB_DEFINITION}
merged.update((k, shipped[k]) for k in _CRON_JOB_DEFINITION if k in shipped)
# repeat pairs the author's budget (times) with this profile's progress (completed).
times = (shipped.get("repeat") or {}).get("times")
merged["repeat"] = {"completed": 0, **(record.get("repeat") or {}), "times": times}
return merged


def _merge_cron_jobs(src: Path, dest: Path) -> None:
"""Merge a shipped ``cron/jobs.json`` into the profile's store job by job, keyed on the job
id the author's store assigned (kept on import, so ``context_from`` chains still resolve).

The store holds every job of the profile, so replacing the file deleted the installer's own
jobs and re-armed shipped ones. A job the profile already has gets the new definition and
keeps its enabled/paused state and run history; a job new to the profile arrives paused, so
nothing a distribution ships runs before the installer resumes it."""
from cron import jobs as cron_jobs

try:
with tempfile.TemporaryDirectory(prefix="hermes_dist_cron_") as tmp:
# load_jobs() repairs legacy shapes in place: read a copy so a local source is never written.
(Path(tmp) / "cron").mkdir()
shutil.copy2(src, Path(tmp) / "cron" / "jobs.json")
with cron_jobs.use_cron_store(tmp):
shipped = {j["id"]: j for j in cron_jobs.load_jobs() if isinstance(j, dict) and j.get("id")}
now = cron_jobs._hermes_now().isoformat()
imported = {
"enabled": False, "state": "paused", "paused_at": now, "created_at": now, "next_run_at": None,
"paused_reason": "Installed from a profile distribution; review it, then resume.",
}
with cron_jobs.use_cron_store(dest.parent.parent), cron_jobs._jobs_lock():
jobs = []
for job in cron_jobs.load_jobs():
ship = shipped.pop(job.get("id"), None)
jobs.append(job if ship is None else _with_job_definition(job, ship))
jobs += [_with_job_definition({"id": job_id, **imported}, ship) for job_id, ship in shipped.items()]
cron_jobs.save_jobs(jobs)
except RuntimeError as exc: # load_jobs() on an unreadable or corrupt store
raise DistributionError(f"Could not merge cron jobs into {dest}: {exc}") from exc


# Owned files the runtime keeps as one store of many records: merged per record, never replaced.
_MERGED_FILES = {("cron", "jobs.json"): _merge_cron_jobs}


def _real_dir(base: Path, parts: Tuple[str, ...]) -> Path:
"""Return ``base/parts`` as a chain of real directories.

Expand Down Expand Up @@ -409,22 +488,26 @@ def _is_container(path: Path) -> bool:
return path.is_dir() and not any(p.is_file() for p in path.iterdir())


def _merge_dir(src: Path, dest: Path) -> None:
"""Replace only the roots *src* ships inside *dest*; a nested container
(``skills/<category>``) is merged, not replaced, so sibling roots the user
def _merge_dir(src: Path, dest: Path, rel: Tuple[str, ...]) -> None:
"""Replace only the roots *src* ships inside *dest* (*rel* from the profile root); a nested
container (``skills/<category>``) is merged, not replaced, so sibling roots the user
added under the same category survive."""
for child in src.iterdir():
parts = (*rel, child.name)
if _is_runtime_state(parts):
continue
if _is_container(child):
_merge_dir(child, _real_dir(dest, (child.name,)))
_merge_dir(child, _real_dir(dest, (child.name,)), parts)
else:
_replace_entry(child, dest / child.name)
_MERGED_FILES.get(parts, _replace_entry)(child, dest / child.name)


def _refuse_symlinked_containers(src: Path, dest: Path) -> None:
def _refuse_symlinked_containers(src: Path, dest: Path, rel: Tuple[str, ...]) -> None:
for child in src.iterdir():
if _is_container(child):
parts = (*rel, child.name)
if _is_container(child) and not _is_runtime_state(parts):
_refuse_symlink(dest / child.name)
_refuse_symlinked_containers(child, dest / child.name)
_refuse_symlinked_containers(child, dest / child.name, parts)


def _refuse_symlinked_targets(target: Path, entries) -> None:
Expand All @@ -440,7 +523,7 @@ def _refuse_symlinked_targets(target: Path, entries) -> None:
path = path / part
_refuse_symlink(path)
if src.is_dir() and len(rel_parts) == 1:
_refuse_symlinked_containers(src, path)
_refuse_symlinked_containers(src, path, rel_parts)


def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifest, preserve_config: bool) -> None:
Expand All @@ -467,10 +550,10 @@ def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifes
if name == "config.yaml" and preserve_config and (target / "config.yaml").exists():
continue
if src.is_dir():
_merge_dir(src, _real_dir(target, rel_parts))
_merge_dir(src, _real_dir(target, rel_parts), rel_parts)
continue
parent = _real_dir(target, rel_parts[:-1])
_replace_entry(src, parent / rel_parts[-1])
_MERGED_FILES.get(rel_parts, _replace_entry)(src, parent / rel_parts[-1])

# Emit .env.EXAMPLE from manifest if the staged tree didn't ship one
if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists():
Expand Down
45 changes: 45 additions & 0 deletions tests/hermes_cli/test_profile_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import stat
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest
Expand Down Expand Up @@ -392,6 +393,25 @@ def test_install_enforces_hermes_requires(self, profile_env, monkeypatch):
with pytest.raises(DistributionError, match="requires Hermes"):
install_distribution(str(staged), name="future")

def test_install_leaves_shipped_cron_jobs_paused_and_not_due(self, profile_env):
"""A shipped job must not run until the installer resumes it, even one that was due
in the author's profile."""
from cron.jobs import create_job, get_due_jobs, is_job_runnable, list_jobs, update_job, use_cron_store

staged = _make_staging_dir(profile_env, "src")
with use_cron_store(staged):
shipped = create_job("Summarise new arXiv papers", "every 1d", name="digest")
update_job(shipped["id"], {"next_run_at": (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()})

plan = install_distribution(str(staged), name="cron_paused")

with use_cron_store(plan.target_dir):
installed = {job["id"]: job for job in list_jobs(include_disabled=True)}
due = get_due_jobs()
assert shipped["id"] in installed
assert not is_job_runnable(installed[shipped["id"]])
assert due == []


# ===========================================================================
# Update β€” preserves user data, preserves config by default
Expand Down Expand Up @@ -437,6 +457,31 @@ def test_update_and_force_install_merge_owned_dirs_per_root(self, profile_env):
assert (custom / "SKILL.md").read_text(encoding="utf-8") == "custom skill\n"
assert (plan.target_dir / "cron" / "mine.json").exists()

def test_update_merges_cron_store_per_job(self, profile_env):
"""cron/jobs.json holds every job of the profile: an update refreshes the definition of a
job the distribution ships and keeps the installer's own jobs and each job's state."""
from cron.jobs import create_job, list_jobs, pause_job, resume_job, update_job, use_cron_store

staged = _make_staging_dir(profile_env, "src")
with use_cron_store(staged):
shipped = create_job("Summarise new arXiv papers", "every 1d", name="digest")
plan = install_distribution(str(staged), name="cron_merge")
with use_cron_store(plan.target_dir):
resume_job(shipped["id"])
mine = create_job("Remind me to water the plants", "0 9 * * *", name="mine")
pause_job(mine["id"])
with use_cron_store(staged):
update_job(shipped["id"], {"prompt": "Summarise new arXiv and bioRxiv papers"})

update_distribution("cron_merge")

with use_cron_store(plan.target_dir):
jobs = {job["id"]: job for job in list_jobs(include_disabled=True)}
assert mine["id"] in jobs, "the installer's own cron job was deleted by the update"
assert jobs[mine["id"]]["state"] == "paused"
assert jobs[shipped["id"]]["prompt"] == "Summarise new arXiv and bioRxiv papers"
assert jobs[shipped["id"]]["enabled"] is True

def test_update_refuses_symlinked_owned_container(self, profile_env):
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="link_safe")
Expand Down
28 changes: 24 additions & 4 deletions website/docs/user-guide/profile-distributions.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,25 @@ backups/
# Logs
errors.log
.hermes_history

# Cron and skills runtime state β€” cron/jobs.json IS your scheduled jobs, so commit it
cron/*.lock
cron/ticker_*
cron/catch_up_occurrences
cron/*.db
cron/*.db-wal
cron/*.db-shm
cron/*.jsonl
cron/suggestions.json
cron/output/
cron/external-workers/
cron/bot_chat_pending/
skills/.hub/
skills/.usage.json
skills/.bundled_manifest
skills/.curator_*
skills/.archive/
skills/.locks/
```

This mirrors the [hard-excluded paths](#whats-not-in-a-distribution-ever) that the installer strips on its end. Anything else you want to keep out of the repo (scratch files, large assets, local-only skills) should also go in here.
Expand Down Expand Up @@ -255,7 +274,7 @@ research-bot/
β”‚ β”œβ”€β”€ paper-summarization/SKILL.md
β”‚ └── citation-lookup/SKILL.md
β”œβ”€β”€ cron/
β”‚ └── weekly-digest.json # scheduled tasks
β”‚ └── jobs.json # scheduled jobs (`hermes cron add`); installed paused
└── README.md # human-facing description (optional)
```

Expand All @@ -265,7 +284,7 @@ When an installer updates to a new version, some things get replaced (author's d

| Category | Paths | On update |
|---|---|---|
| **Distribution-owned** | `SOUL.md`, `config.yaml`, `mcp.json`, `skills/`, `cron/`, `distribution.yaml` | Files are replaced from the new clone. Directories are merged per entry: each skill or cron job the new clone ships replaces its counterpart wholesale (files the author retired disappear), while skills or cron jobs you added yourself stay in place. |
| **Distribution-owned** | `SOUL.md`, `config.yaml`, `mcp.json`, `skills/`, `cron/`, `distribution.yaml` | Files are replaced from the new clone. Directories are merged per entry: each skill the new clone ships replaces its counterpart wholesale (files the author retired disappear), while skills you added yourself stay in place. `cron/jobs.json` is merged job by job: a job the new clone ships gets its new definition but keeps whether you paused or resumed it and its run history, a job new to your profile arrives paused, and jobs you added yourself stay in place. Runtime state inside `cron/` and `skills/` is never replaced. |
| **Config override** | `config.yaml` | Actually preserved by default β€” the installer may have tuned model or provider. Pass `--force-config` on update to reset. |
| **User-owned** | `memories/`, `sessions/`, `state.db*`, `auth.json`, `.env`, `logs/`, `workspace/`, `plans/`, `home/`, `*_cache/`, `local/` | Never touched |

Expand Down Expand Up @@ -403,7 +422,7 @@ hermes profile update research-bot
What happens:

1. Re-clones the repo from the recorded source URL.
2. Replaces distribution-owned files (SOUL, mcp.json) and every skill or cron job the distribution ships; skills and cron jobs you added to the profile yourself are left alone.
2. Replaces distribution-owned files (SOUL, mcp.json) and every skill the distribution ships, and refreshes the definition of every cron job it ships without resuming or pausing it; skills and cron jobs you added to the profile yourself are left alone.
3. **Preserves** your `config.yaml` β€” you may have tuned the model, temperature, or other settings. Pass `--force-config` to overwrite.
4. **Never touches** user data: memories, sessions, auth, `.env`, logs, state.

Expand Down Expand Up @@ -714,6 +733,7 @@ The installer hard-excludes these paths even if an author accidentally ships the
- `home/` β€” user's home mount in Docker backends
- `*_cache/` β€” image / audio / document caches
- `local/` β€” user-reserved customization namespace
- Runtime state inside `cron/` and `skills/` β€” locks, ticker markers, the execution and delivery ledgers, `cron/output/`, the skills hub, usage and curator state (the [Step 3](#step-3--create-a-gitignore-before-the-first-commit) list)

When you clone a distribution as an installer, these simply aren't copied into your profile directory. When you update, your copies stay put. If you installed the same distribution on five machines, you have five isolated sets of this data β€” one per machine.

Expand All @@ -728,7 +748,7 @@ Profile distributions are unsigned by default. You're trusting:
- **The git host** (GitHub / GitLab / wherever) to serve the bytes the author pushed.
- **The author** to not ship a malicious SOUL, skills, or cron jobs.

Cron jobs from a distribution are **not auto-scheduled** β€” the installer prints `hermes -p <name> cron list` and you enable them explicitly. SOUL.md and skills ARE active as soon as you start chatting with the profile, so read them before your first run if you're installing from someone you don't know.
Cron jobs from a distribution are **not auto-scheduled** β€” they are installed paused, the installer prints `hermes -p <name> cron list`, and you enable each one with `hermes -p <name> cron resume <job-id>`. SOUL.md and skills ARE active as soon as you start chatting with the profile, so read them before your first run if you're installing from someone you don't know.

Rough analogy: installing a distribution is like installing a browser extension or a VS Code extension. Low friction, high power, trust the source. For internal company distributions, use a private repo and your normal git auth β€” nothing new to configure.

Expand Down