"""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= herdr agent attach `. 2: no `kill_command` / `start_command`; `attach_command` is lib_py.agents.sanitize.attach_command(, ). 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 runs that migration through the writer (same lock, YAML rewritten), prints one line `registry-format: -> , rows changed: ` 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())