feat(registry): format version 2, migrated by the writer (Issue #71)

- lib_py/registry_format.py: a pure, idempotent migrate() drops
  kill_command/start_command and rebuilds attach_command on every row
  (stopped/terminated included) and sets the top-level registry_format key.
- atomic_dump_yaml migrates an older registry under its lock, before the
  caller's mutation, and then rewrites the YAML as well as the DB (the YAML
  write is otherwise skipped when only fields change). load_state_json stays
  read-only. New registries are written with the current version.
- Entry point for #72: `python -m lib_py.registry_format --yaml <path>` runs
  the migration through the writer and prints
  `registry-format: <from> -> <to>, rows changed: <n>`.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-25 15:16:46 +00:00
1 parent 50e2998a20
commit 9c21fa4e4f
3 files changed
+117 -1

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
"""Registry format version and its migration (Issue #71).
The session registry (agent-sessions.yaml + agent-sessions.db) carries a
top-level `registry_format` key; it lives in the DB `state` row like every
other top-level key. A registry without it is format 1.
1: rows may carry `kill_command` and `start_command`, and `attach_command`
reads `HERDR_SESSION_NAME=<server> herdr agent attach <original name>`.
2: no `kill_command` / `start_command`; `attach_command` is
lib_py.agents.sanitize.attach_command(<row server>, <row name>).
The writer (lib_py.atomic_yaml, the only code that writes the registry)
migrates an older registry under its lock before the caller's mutation, and
then rewrites the YAML as well as the DB. Readers (lib_py.state) never
migrate; they see the old rows until the first write.
Entry point (for #72, the update skill):
python -m lib_py.registry_format --yaml <agent-sessions.yaml>
runs that migration through the writer (same lock, YAML rewritten), prints one
line `registry-format: <from> -> <to>, rows changed: <n>` and exits 0 when the
registry was migrated or already current, nonzero on error. Run it with the
skills directory on PYTHONPATH (as lib.sh does), e.g.
`PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml .mam/agent-sessions.yaml`.
"""
import argparse
import copy
import os
import sys
from lib_py.agents.sanitize import attach_command
FORMAT_KEY = "registry_format"
CURRENT = 2
_DROPPED = ("kill_command", "start_command")
def stored_version(d):
"""The format a registry dict says it has (1 when it says nothing)."""
v = d.get(FORMAT_KEY) if isinstance(d, dict) else None
return v if isinstance(v, int) and not isinstance(v, bool) else 1
def _row_server(s):
return s.get("herdr_session") or s.get("herdr_server") or "default"
def migrate(d):
"""Return (new registry dict, rows changed). Pure and idempotent: `d` is
not modified, and migrating the result again changes nothing. Every row is
migrated, `stopped` and `terminated` ones included."""
out = copy.deepcopy(d) if isinstance(d, dict) else {}
changed = 0
for s in out.get("herdr_sessions") or []:
if not isinstance(s, dict) or not s.get("name"):
continue
before = dict(s)
for k in _DROPPED:
s.pop(k, None)
s["attach_command"] = attach_command(_row_server(s), s["name"])
if s != before:
changed += 1
out[FORMAT_KEY] = CURRENT
return out, changed
def main(argv=None):
ap = argparse.ArgumentParser(prog="python -m lib_py.registry_format",
description="Migrate the session registry to the current format.")
ap.add_argument("--yaml", required=True, help="path of agent-sessions.yaml")
a = ap.parse_args(argv)
yaml_path = os.path.abspath(a.yaml)
db_path = os.path.splitext(yaml_path)[0] + ".db"
if not (os.path.exists(yaml_path) or os.path.exists(db_path)):
print("registry-format: error: no registry at %s" % yaml_path, file=sys.stderr)
return 1
from lib_py.atomic_yaml import atomic_dump_yaml_main
os.environ["YAML_PATH"] = yaml_path
os.environ["AGENT_SESSIONS_MUTATION"] = "pass"
try:
frm, to, n = atomic_dump_yaml_main()
except (Exception, SystemExit) as exc: # the writer refuses with SystemExit
print("registry-format: error: %s" % (exc,), file=sys.stderr)
return 1
print("registry-format: %d -> %d, rows changed: %d" % (frm, to, n), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())