The demo tooling only existed in a session scratch directory, which is
temporary. This puts it in the repo so the video can be rebuilt.
Terminal only: tmux drives a two pane session, asciinema records it, agg
renders it, ffmpeg encodes it. The left pane runs the loop against a live
ZVM, the right pane polls the VPG journal so the tagged checkpoint appears
on camera as it lands.
Narration is synthesised per beat and aligned to marks the driver writes
while it runs, rather than to predicted timings, so a slow API call or an
FLR mount that takes longer than usual does not drift the audio. Two
things that has to respect are written down in the README: agg's
idle-time-limit must exceed the longest pause or it compresses idle time
and breaks the wall-clock mapping, and each beat holds for its narration
length so no line is cut off.
demo_win.json carries live guest credentials, so only an example with
placeholders is committed and the real file is gitignored, along with the
generated wav, cast, gif and mp4.
The xAI key path and voice id come from the environment now instead of
being hardcoded to one machine.
Also records the narration gotchas that cost time: mapping an acronym to
run-together phonetics ({"VM": "vee em"}) is spoken as one word, "vem";
prose written for the page sounds robotic read aloud; and volumedetect
reports no samples when aimed at a file whose first stream is video,
which makes a working audio track look silent.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016yVfC5nvZowoLFnEGWhLGn
242 lines
9.6 KiB
Python
242 lines
9.6 KiB
Python
"""Left pane: drive zerto_rewind_mcp over real MCP stdio. Full rewind loop."""
|
|
from __future__ import annotations
|
|
import asyncio, json, os, shlex, subprocess, sys, time
|
|
from pathlib import Path
|
|
|
|
from mcp import ClientSession, StdioServerParameters
|
|
from mcp.client.stdio import stdio_client
|
|
|
|
SP = Path(__file__).parent
|
|
CFG = json.loads((SP / "demo.json").read_text())
|
|
REPO = "/home/justin/github/zerto-ai-rewind"
|
|
|
|
RST, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
|
|
CYAN, GREEN, RED, YEL, MAG = "\033[36m", "\033[32m", "\033[31m", "\033[33m", "\033[35m"
|
|
|
|
SPEED = float(os.environ.get("DEMO_SPEED", "1.0"))
|
|
|
|
|
|
def w(s=""):
|
|
sys.stdout.write(s + "\n"); sys.stdout.flush()
|
|
|
|
|
|
def type_out(s, delay=0.012, color=""):
|
|
sys.stdout.write(color)
|
|
for ch in s:
|
|
sys.stdout.write(ch); sys.stdout.flush()
|
|
time.sleep(delay * SPEED)
|
|
sys.stdout.write(RST + "\n"); sys.stdout.flush()
|
|
|
|
|
|
def beat(n=1.2):
|
|
time.sleep(n * SPEED)
|
|
|
|
|
|
STEP = [0]
|
|
|
|
|
|
def step(title):
|
|
STEP[0] += 1
|
|
w()
|
|
w(f"{BOLD}{CYAN}{'='*66}{RST}")
|
|
type_out(f" STEP {STEP[0]} {title}", 0.008, BOLD + CYAN)
|
|
w(f"{BOLD}{CYAN}{'='*66}{RST}")
|
|
beat(0.5)
|
|
|
|
|
|
def note(s):
|
|
type_out(f" {s}", 0.010, DIM)
|
|
|
|
|
|
def call_banner(tool, args):
|
|
w(f"{MAG} -> MCP call{RST} {BOLD}{tool}{RST}")
|
|
for k, v in args.items():
|
|
w(f"{DIM} {k} = {v}{RST}")
|
|
|
|
|
|
def show(payload, keep=None, limit=22):
|
|
if keep:
|
|
payload = {k: payload[k] for k in keep if k in payload}
|
|
text = json.dumps(payload, indent=2, default=str)
|
|
lines = text.splitlines()
|
|
for line in lines[:limit]:
|
|
w(f"{DIM} |{RST} {line}")
|
|
if len(lines) > limit:
|
|
w(f"{DIM} | ... {len(lines)-limit} more lines{RST}")
|
|
|
|
|
|
def ssh_base():
|
|
if CFG.get("password"):
|
|
return ["sshpass", "-p", CFG["password"], "ssh", "-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"]
|
|
base = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "LogLevel=ERROR"]
|
|
if CFG.get("key"):
|
|
base += ["-i", CFG["key"]]
|
|
return base
|
|
|
|
|
|
def guest(cmd, show_cmd=True):
|
|
target = f"{CFG['user']}@{CFG['host']}"
|
|
full = ssh_base() + [target, cmd]
|
|
if show_cmd:
|
|
w(f"{YEL} $ ssh {target} {shlex.quote(cmd)}{RST}")
|
|
r = subprocess.run(full, capture_output=True, text=True, timeout=60)
|
|
out = (r.stdout + r.stderr).rstrip()
|
|
for line in out.splitlines():
|
|
w(f"{DIM} |{RST} {line}")
|
|
return r.returncode, out
|
|
|
|
|
|
async def main():
|
|
w(f"\n{BOLD}{CYAN} ZERTO AI REWIND{RST} {DIM}live end-to-end{RST}\n")
|
|
type_out(" An agent is about to change a file on a protected VM.", 0.014)
|
|
type_out(" Zerto already journals that VM. Make the agent use it.", 0.014)
|
|
beat(1.5)
|
|
|
|
params = StdioServerParameters(
|
|
command=f"{REPO}/.venv/bin/zerto-rewind-mcp",
|
|
env={**os.environ, "ZERTO_REWIND_CONFIG": f"{REPO}/config.json"},
|
|
)
|
|
devnull = open(os.devnull, 'w')
|
|
async with stdio_client(params, errlog=devnull) as (r, s):
|
|
async with ClientSession(r, s) as sess:
|
|
step("Connect to the rewind MCP")
|
|
await sess.initialize()
|
|
tools = await sess.list_tools()
|
|
note(f"{len(tools.tools)} tools from zerto_rewind_mcp")
|
|
for t in tools.tools:
|
|
w(f"{DIM} - {t.name}{RST}")
|
|
beat(2.0)
|
|
|
|
async def _ticker(label):
|
|
t0 = time.time()
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(5)
|
|
el = int(time.time() - t0)
|
|
sys.stdout.write(f"\r{DIM} {label} ... {el}s{RST}")
|
|
sys.stdout.flush()
|
|
except asyncio.CancelledError:
|
|
el = int(time.time() - t0)
|
|
if el >= 5:
|
|
sys.stdout.write(f"\r{DIM} {label} ... {el}s done{RST}\n")
|
|
else:
|
|
sys.stdout.write("\r" + " " * 50 + "\r")
|
|
sys.stdout.flush()
|
|
raise
|
|
|
|
async def call(name, **args):
|
|
call_banner(name, args)
|
|
tick = asyncio.create_task(_ticker("working"))
|
|
try:
|
|
res = await sess.call_tool(name, args)
|
|
finally:
|
|
tick.cancel()
|
|
try:
|
|
await tick
|
|
except asyncio.CancelledError:
|
|
pass
|
|
return json.loads(res.content[0].text)
|
|
|
|
# ---- 1. the good state on the guest
|
|
step("The file the agent is about to break")
|
|
guest(f"cat {CFG['guest_path']}")
|
|
beat(1.8)
|
|
|
|
# ---- 2. find protection
|
|
step("zerto_find_protection - is this VM protected?")
|
|
note("Reads skip the guard. This is the read.")
|
|
found = await call("zerto_find_protection", query=CFG["vm_query"])
|
|
show(found)
|
|
if not found.get("ok"):
|
|
w(f"{RED} find_protection not ok - stopping{RST}"); return 1
|
|
vm = found["vm"]
|
|
beat(2.5)
|
|
|
|
# ---- 3. guard
|
|
step("zerto_guard_before_mutate - pin the journal FIRST")
|
|
note("Capture-before-execute. No tag, no change.")
|
|
note("The checkpoint name records which agent, and what it is about to do.")
|
|
note("Watch the journal pane on the right.")
|
|
guard = await call("zerto_guard_before_mutate",
|
|
query=CFG["vm_query"], change_id=CFG["change_id"], agent="claude",
|
|
action=f"edit {CFG['guest_path']}")
|
|
show(guard, keep=["ok", "tag", "tagged", "skipped", "errors", "message"])
|
|
if not guard.get("ok"):
|
|
w(f"{RED} GUARD FAILED - refusing the change{RST}"); return 1
|
|
tag = guard["tag"]
|
|
tagged = guard["tagged"][0]
|
|
t_tag = time.strftime("%H:%M:%S")
|
|
w(f"{GREEN}{BOLD} tagged checkpoint {tagged['checkpoint_id']} on {tagged['vpg_name']}{RST}")
|
|
w(f"{GREEN} inserted at {t_tag}{RST}")
|
|
w(f"{GREEN} name: {tag}{RST}")
|
|
w(f"{DIM} this checkpoint is the rewind point. remember cp {tagged['checkpoint_id']}.{RST}")
|
|
beat(4.0)
|
|
|
|
# ---- 4. the bad change
|
|
step("Now the agent makes the bad change")
|
|
guest(f"printf '%s\\n' {shlex.quote(CFG['bad_content'])} > {CFG['guest_path']}")
|
|
guest(f"cat {CFG['guest_path']}")
|
|
t_bad = time.strftime("%H:%M:%S")
|
|
w(f"{RED}{BOLD} the good content is gone from the guest ({t_bad}){RST}")
|
|
note("Git never had this file. Last night's backup is hours stale.")
|
|
beat(2.5)
|
|
|
|
# ---- 5. refusal without a human
|
|
step("zerto_recover_file without a human yes")
|
|
deny = await call("zerto_recover_file",
|
|
vpg_identifier=tagged["vpg_identifier"],
|
|
vm_identifier=vm["vm_identifier"],
|
|
checkpoint_identifier=tagged["checkpoint_id"],
|
|
guest_path=CFG["guest_path"], confirmed=False)
|
|
show(deny)
|
|
w(f"{YEL} refused. FLR mounts a disk; a human says yes.{RST}")
|
|
beat(2.5)
|
|
|
|
# ---- 6. recover
|
|
rec_cp = tagged["checkpoint_id"]
|
|
step("Human says yes - FLR from the pre-mutation checkpoint")
|
|
w(f"{BOLD} provenance{RST}")
|
|
w(f" {GREEN}cp {tagged['checkpoint_id']} inserted {t_tag}{RST} {DIM}<- guard, BEFORE the change{RST}")
|
|
w(f" {RED}bad write {t_bad}{RST} {DIM}<- the mutation, AFTER{RST}")
|
|
assert rec_cp == tagged["checkpoint_id"], "recovery must use the guard checkpoint"
|
|
w(f"{DIM} recovering from cp {rec_cp}, not the newest checkpoint.{RST}")
|
|
note(f"name: {tag}")
|
|
rec = await call("zerto_recover_file",
|
|
vpg_identifier=tagged["vpg_identifier"],
|
|
vm_identifier=vm["vm_identifier"],
|
|
checkpoint_identifier=tagged["checkpoint_id"],
|
|
guest_path=CFG["guest_path"], confirmed=True)
|
|
show(rec)
|
|
if not rec.get("ok"):
|
|
w(f"{RED} FLR failed{RST}"); return 1
|
|
beat(1.5)
|
|
w(f"{YEL} $ cat {rec['path']}{RST}")
|
|
body = Path(rec["path"]).read_text()
|
|
for line in body.splitlines():
|
|
w(f"{GREEN}{BOLD} | {line}{RST}")
|
|
beat(2.5)
|
|
|
|
# ---- 7. put it back
|
|
step("Copy it back to the guest")
|
|
note("This part is scp, not Zerto. Zerto got the bytes back.")
|
|
target = f"{CFG['user']}@{CFG['host']}"
|
|
scp = (["sshpass", "-p", CFG["password"]] if CFG.get("password") else []) + \
|
|
["scp", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "LogLevel=ERROR"] + (["-i", CFG["key"]] if CFG.get("key") else []) + \
|
|
[rec["path"], f"{target}:{CFG['guest_path']}"]
|
|
w(f"{YEL} $ scp {Path(rec['path']).name} {target}:{CFG['guest_path']}{RST}")
|
|
subprocess.run(scp, capture_output=True, text=True, timeout=60)
|
|
guest(f"cat {CFG['guest_path']}")
|
|
beat(1.5)
|
|
|
|
w()
|
|
w(f"{BOLD}{GREEN}{'='*66}{RST}")
|
|
type_out(" REWOUND. RPO was the journal, not last night.", 0.016, BOLD + GREEN)
|
|
w(f"{BOLD}{GREEN}{'='*66}{RST}")
|
|
beat(4.0)
|
|
return 0
|
|
|
|
sys.exit(asyncio.run(main()))
|