cgcardona / muse public
demo.py python
518 lines 16.7 KB
fd2b304c Remove all 'Tour de Force' verbiage — rename to Demo everywhere Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 #!/usr/bin/env python3
2 """Muse Demo — 5-act VCS stress test + shareable visualization.
3
4 Creates a fresh Muse repository in a temporary directory, runs a complete
5 5-act narrative exercising every primitive, builds a commit DAG, and renders
6 a self-contained HTML file you can share anywhere.
7
8 Usage
9 -----
10 python tools/demo.py
11 python tools/demo.py --output-dir my_output/
12 python tools/demo.py --json-only # skip HTML rendering
13
14 Output
15 ------
16 artifacts/demo.json — structured event log + DAG
17 artifacts/demo.html — shareable visualization
18 """
19
20 import argparse
21 import json
22 import os
23 import pathlib
24 import sys
25 import time
26 from datetime import datetime, timezone
27 from typing import TypedDict
28
29 # Ensure both the repo root (muse package) and tools/ (render_html) are importable.
30 _REPO_ROOT = pathlib.Path(__file__).parent.parent
31 _TOOLS_DIR = pathlib.Path(__file__).parent
32 for _p in (str(_REPO_ROOT), str(_TOOLS_DIR)):
33 if _p not in sys.path:
34 sys.path.insert(0, _p)
35
36 from muse.cli.app import cli # noqa: E402
37 from muse.core.merge_engine import clear_merge_state # noqa: E402 (used in act4)
38 from typer.testing import CliRunner # noqa: E402
39
40 RUNNER = CliRunner()
41
42 BRANCH_COLORS: dict[str, str] = {
43 "main": "#4f8ef7",
44 "alpha": "#f9a825",
45 "beta": "#66bb6a",
46 "gamma": "#ab47bc",
47 "conflict/left": "#ef5350",
48 "conflict/right": "#ff7043",
49 }
50
51 ACT_TITLES: dict[int, str] = {
52 1: "Foundation",
53 2: "Divergence",
54 3: "Clean Merges",
55 4: "Conflict & Resolution",
56 5: "Advanced Operations",
57 }
58
59
60 # ---------------------------------------------------------------------------
61 # TypedDicts for the structured event log
62 # ---------------------------------------------------------------------------
63
64
65 class EventRecord(TypedDict):
66 act: int
67 act_title: str
68 step: int
69 op: str
70 cmd: str
71 duration_ms: float
72 exit_code: int
73 output: str
74 commit_id: str | None
75
76
77 class CommitNode(TypedDict):
78 id: str
79 short: str
80 message: str
81 branch: str
82 parents: list[str]
83 timestamp: str
84 files: list[str]
85 files_changed: int
86
87
88 class BranchRef(TypedDict):
89 name: str
90 head: str
91 color: str
92
93
94 class DAGData(TypedDict):
95 commits: list[CommitNode]
96 branches: list[BranchRef]
97
98
99 class TourMeta(TypedDict):
100 domain: str
101 muse_version: str
102 generated_at: str
103 elapsed_s: str
104
105
106 class TourStats(TypedDict):
107 commits: int
108 branches: int
109 merges: int
110 conflicts_resolved: int
111 operations: int
112
113
114 class DemoData(TypedDict):
115 meta: TourMeta
116 stats: TourStats
117 dag: DAGData
118 events: list[EventRecord]
119
120
121 # ---------------------------------------------------------------------------
122 # Global runner state
123 # ---------------------------------------------------------------------------
124
125 _events: list[EventRecord] = []
126 _step = 0
127 _current_act = 0
128
129
130 # ---------------------------------------------------------------------------
131 # Runner helpers
132 # ---------------------------------------------------------------------------
133
134
135 def _run(
136 op: str,
137 args: list[str],
138 root: pathlib.Path,
139 *,
140 expect_fail: bool = False,
141 ) -> tuple[int, str]:
142 """Invoke a muse CLI command, capture output and timing."""
143 global _step, _current_act
144 _step += 1
145 old_cwd = pathlib.Path.cwd()
146 os.chdir(root)
147 t0 = time.perf_counter()
148 try:
149 result = RUNNER.invoke(cli, args)
150 finally:
151 os.chdir(old_cwd)
152 duration_ms = (time.perf_counter() - t0) * 1000
153 output = (result.output or "").strip()
154 short_id = _extract_short_id(output)
155
156 mark = "✓" if result.exit_code == 0 else ("⚠" if expect_fail else "✗")
157 print(f" {mark} muse {' '.join(str(a) for a in args)}")
158 if result.exit_code != 0 and not expect_fail:
159 print(f" output: {output[:160]}")
160
161 _events.append(EventRecord(
162 act=_current_act,
163 act_title=ACT_TITLES.get(_current_act, ""),
164 step=_step,
165 op=op,
166 cmd="muse " + " ".join(str(a) for a in args),
167 duration_ms=round(duration_ms, 1),
168 exit_code=result.exit_code,
169 output=output,
170 commit_id=short_id,
171 ))
172 return result.exit_code, output
173
174
175 def _write(root: pathlib.Path, filename: str, content: str = "") -> None:
176 """Write a file to muse-work/."""
177 workdir = root / "muse-work"
178 workdir.mkdir(exist_ok=True)
179 body = content or f"# {filename}\nformat: muse-state\nversion: 1\n"
180 (workdir / filename).write_text(body)
181
182
183 def _extract_short_id(output: str) -> str | None:
184 """Extract an 8-char hex commit short-ID from CLI output."""
185 import re
186 patterns = [
187 r"\[(?:\S+)\s+([0-9a-f]{8})\]", # [main a1b2c3d4]
188 r"Merged.*?\(([0-9a-f]{8})\)", # Merged 'x' into 'y' (id)
189 r"Fast-forward to ([0-9a-f]{8})", # Fast-forward to id
190 r"Cherry-picked.*?([0-9a-f]{8})\b", # Cherry-picked …
191 ]
192 for p in patterns:
193 m = re.search(p, output)
194 if m:
195 return m.group(1)
196 return None
197
198
199 def _head_id(root: pathlib.Path, branch: str) -> str:
200 """Read the full commit ID for a branch from refs/heads/."""
201 parts = branch.split("/")
202 ref_file = root / ".muse" / "refs" / "heads" / pathlib.Path(*parts)
203 if ref_file.exists():
204 return ref_file.read_text().strip()
205 return ""
206
207
208 # ---------------------------------------------------------------------------
209 # Act 1 — Foundation
210 # ---------------------------------------------------------------------------
211
212
213 def act1(root: pathlib.Path) -> None:
214 global _current_act
215 _current_act = 1
216 print("\n=== Act 1: Foundation ===")
217 _run("init", ["init"], root)
218
219 _write(root, "root-state.mid", "# root-state\nformat: muse-music\nbeats: 4\ntempo: 120\n")
220 _run("commit", ["commit", "-m", "Root: initial state snapshot"], root)
221
222 _write(root, "layer-1.mid", "# layer-1\ndimension: rhythmic\npattern: 4/4\n")
223 _run("commit", ["commit", "-m", "Layer 1: add rhythmic dimension"], root)
224
225 _write(root, "layer-2.mid", "# layer-2\ndimension: harmonic\nkey: Cmaj\n")
226 _run("commit", ["commit", "-m", "Layer 2: add harmonic dimension"], root)
227
228 _run("log", ["log", "--oneline"], root)
229
230
231 # ---------------------------------------------------------------------------
232 # Act 2 — Divergence
233 # ---------------------------------------------------------------------------
234
235
236 def act2(root: pathlib.Path) -> dict[str, str]:
237 global _current_act
238 _current_act = 2
239 print("\n=== Act 2: Divergence ===")
240
241 # Branch: alpha — textural variations
242 _run("checkout_alpha", ["checkout", "-b", "alpha"], root)
243 _write(root, "alpha-a.mid", "# alpha-a\ntexture: sparse\nlayer: high\n")
244 _run("commit", ["commit", "-m", "Alpha: texture pattern A (sparse)"], root)
245 _write(root, "alpha-b.mid", "# alpha-b\ntexture: dense\nlayer: mid\n")
246 _run("commit", ["commit", "-m", "Alpha: texture pattern B (dense)"], root)
247
248 # Branch: beta — rhythm explorations (from main)
249 _run("checkout_main_1", ["checkout", "main"], root)
250 _run("checkout_beta", ["checkout", "-b", "beta"], root)
251 _write(root, "beta-a.mid", "# beta-a\nrhythm: syncopated\nsubdiv: 16th\n")
252 _run("commit", ["commit", "-m", "Beta: syncopated rhythm pattern"], root)
253
254 # Branch: gamma — melodic lines (from main)
255 _run("checkout_main_2", ["checkout", "main"], root)
256 _run("checkout_gamma", ["checkout", "-b", "gamma"], root)
257 _write(root, "gamma-a.mid", "# gamma-a\nmelody: ascending\ninterval: 3rd\n")
258 _run("commit", ["commit", "-m", "Gamma: ascending melody A"], root)
259 gamma_a_id = _head_id(root, "gamma")
260
261 _write(root, "gamma-b.mid", "# gamma-b\nmelody: descending\ninterval: 5th\n")
262 _run("commit", ["commit", "-m", "Gamma: descending melody B"], root)
263
264 _run("log", ["log", "--oneline"], root)
265 return {"gamma_a": gamma_a_id}
266
267
268 # ---------------------------------------------------------------------------
269 # Act 3 — Clean Merges
270 # ---------------------------------------------------------------------------
271
272
273 def act3(root: pathlib.Path) -> None:
274 global _current_act
275 _current_act = 3
276 print("\n=== Act 3: Clean Merges ===")
277
278 _run("checkout_main", ["checkout", "main"], root)
279 _run("merge_alpha", ["merge", "alpha"], root)
280 _run("status", ["status"], root)
281 _run("merge_beta", ["merge", "beta"], root)
282 _run("log", ["log", "--oneline"], root)
283
284
285 # ---------------------------------------------------------------------------
286 # Act 4 — Conflict & Resolution
287 # ---------------------------------------------------------------------------
288
289
290 def act4(root: pathlib.Path) -> None:
291 global _current_act
292 _current_act = 4
293 print("\n=== Act 4: Conflict & Resolution ===")
294
295 # conflict/left: introduce shared-state.mid (version A)
296 _run("checkout_left", ["checkout", "-b", "conflict/left"], root)
297 _write(root, "shared-state.mid", "# shared-state\nversion: A\nsource: left-branch\n")
298 _run("commit", ["commit", "-m", "Left: introduce shared state (version A)"], root)
299
300 # conflict/right: introduce shared-state.mid (version B) — from main before left merge
301 _run("checkout_main", ["checkout", "main"], root)
302 _run("checkout_right", ["checkout", "-b", "conflict/right"], root)
303 _write(root, "shared-state.mid", "# shared-state\nversion: B\nsource: right-branch\n")
304 _run("commit", ["commit", "-m", "Right: introduce shared state (version B)"], root)
305
306 # Merge left into main cleanly (main didn't have shared-state.mid yet)
307 _run("checkout_main", ["checkout", "main"], root)
308 _run("merge_left", ["merge", "conflict/left"], root)
309
310 # Merge right → CONFLICT (both sides added shared-state.mid with different content)
311 _run("merge_right", ["merge", "conflict/right"], root, expect_fail=True)
312
313 # Resolve: write reconciled content, clear merge state, commit
314 print(" → Resolving conflict: writing reconciled shared-state.mid")
315 resolved = (
316 "# shared-state\n"
317 "version: RESOLVED\n"
318 "source: merged A+B\n"
319 "notes: manual reconciliation\n"
320 )
321 (root / "muse-work" / "shared-state.mid").write_text(resolved)
322 clear_merge_state(root)
323 _run("resolve_commit", ["commit", "-m", "Resolve: integrate shared-state (A+B reconciled)"], root)
324
325 _run("status", ["status"], root)
326
327
328 # ---------------------------------------------------------------------------
329 # Act 5 — Advanced Operations
330 # ---------------------------------------------------------------------------
331
332
333 def act5(root: pathlib.Path, saved_ids: dict[str, str]) -> None:
334 global _current_act
335 _current_act = 5
336 print("\n=== Act 5: Advanced Operations ===")
337
338 gamma_a_id = saved_ids.get("gamma_a", "")
339
340 # Cherry-pick: bring Gamma: melody A into main without merging all of gamma
341 if gamma_a_id:
342 _run("cherry_pick", ["cherry-pick", gamma_a_id], root)
343 cherry_pick_head = _head_id(root, "main")
344
345 # Inspect the resulting commit
346 _run("show", ["show"], root)
347 _run("diff", ["diff"], root)
348
349 # Stash: park uncommitted work, then pop it back
350 _write(root, "wip-experiment.mid", "# wip-experiment\nstatus: in-progress\ndo-not-commit: true\n")
351 _run("stash", ["stash"], root)
352 _run("status", ["status"], root)
353 _run("stash_pop", ["stash", "pop"], root)
354
355 # Revert the cherry-pick (cherry-pick becomes part of history, revert undoes it)
356 if cherry_pick_head:
357 _run("revert", ["revert", "-m", "Revert: undo gamma cherry-pick", cherry_pick_head], root)
358
359 # Tag the current HEAD
360 _run("tag_add", ["tag", "add", "release:v1.0"], root)
361 _run("tag_list", ["tag", "list"], root)
362
363 # Full history sweep
364 _run("log_stat", ["log", "--stat"], root)
365
366
367 # ---------------------------------------------------------------------------
368 # DAG builder
369 # ---------------------------------------------------------------------------
370
371
372 def build_dag(root: pathlib.Path) -> DAGData:
373 """Read all commits from .muse/commits/ and construct the full DAG."""
374 commits_dir = root / ".muse" / "commits"
375 raw: list[CommitNode] = []
376
377 if commits_dir.exists():
378 for f in commits_dir.glob("*.json"):
379 try:
380 data: dict[str, object] = json.loads(f.read_text())
381 except (json.JSONDecodeError, OSError):
382 continue
383
384 parents: list[str] = []
385 p1 = data.get("parent_commit_id")
386 p2 = data.get("parent2_commit_id")
387 if isinstance(p1, str) and p1:
388 parents.append(p1)
389 if isinstance(p2, str) and p2:
390 parents.append(p2)
391
392 files: list[str] = []
393 snap_id = data.get("snapshot_id", "")
394 if isinstance(snap_id, str):
395 snap_file = root / ".muse" / "snapshots" / f"{snap_id}.json"
396 if snap_file.exists():
397 try:
398 snap = json.loads(snap_file.read_text())
399 files = sorted(snap.get("manifest", {}).keys())
400 except (json.JSONDecodeError, OSError):
401 pass
402
403 commit_id = str(data.get("commit_id", ""))
404 raw.append(CommitNode(
405 id=commit_id,
406 short=commit_id[:8],
407 message=str(data.get("message", "")),
408 branch=str(data.get("branch", "main")),
409 parents=parents,
410 timestamp=str(data.get("committed_at", "")),
411 files=files,
412 files_changed=len(files),
413 ))
414
415 raw.sort(key=lambda c: c["timestamp"])
416
417 branches: list[BranchRef] = []
418 refs_dir = root / ".muse" / "refs" / "heads"
419 if refs_dir.exists():
420 for ref in refs_dir.rglob("*"):
421 if ref.is_file():
422 branch_name = ref.relative_to(refs_dir).as_posix()
423 head_id = ref.read_text().strip()
424 branches.append(BranchRef(
425 name=branch_name,
426 head=head_id,
427 color=BRANCH_COLORS.get(branch_name, "#78909c"),
428 ))
429
430 branches.sort(key=lambda b: b["name"])
431 return DAGData(commits=raw, branches=branches)
432
433
434 # ---------------------------------------------------------------------------
435 # Main
436 # ---------------------------------------------------------------------------
437
438
439 def main() -> None:
440 parser = argparse.ArgumentParser(
441 description="Muse Demo — stress test + visualization generator",
442 )
443 parser.add_argument(
444 "--output-dir",
445 default=str(_REPO_ROOT / "artifacts"),
446 help="Directory to write output files (default: artifacts/)",
447 )
448 parser.add_argument(
449 "--json-only",
450 action="store_true",
451 help="Write JSON only, skip HTML rendering",
452 )
453 args = parser.parse_args()
454
455 output_dir = pathlib.Path(args.output_dir)
456 output_dir.mkdir(parents=True, exist_ok=True)
457
458 import tempfile
459 with tempfile.TemporaryDirectory() as tmp:
460 root = pathlib.Path(tmp)
461 print(f"Muse Demo — repo: {root}")
462 t_start = time.perf_counter()
463
464 saved_ids: dict[str, str] = {}
465 act1(root)
466 saved_ids.update(act2(root))
467 act3(root)
468 act4(root)
469 act5(root, saved_ids)
470
471 elapsed = time.perf_counter() - t_start
472 print(f"\n✓ Acts 1–5 complete in {elapsed:.2f}s — {_step} operations")
473
474 dag = build_dag(root)
475
476 total_commits = len(dag["commits"])
477 total_branches = len(dag["branches"])
478 merge_commits = sum(1 for c in dag["commits"] if len(c["parents"]) >= 2)
479 conflicts = sum(1 for e in _events if not e["exit_code"] == 0 and "conflict" in e["output"].lower())
480
481 elapsed_total = time.perf_counter() - t_start
482 print(f"\n✓ Demo complete — {_step} operations in {elapsed_total:.2f}s")
483 print(" Engine capabilities (Typed Deltas, Domain Schema, OT Merge, CRDT)")
484 print(" → see artifacts/domain_registry.html")
485
486 tour: DemoData = DemoData(
487 meta=TourMeta(
488 domain="midi",
489 muse_version="0.1.1",
490 generated_at=datetime.now(timezone.utc).isoformat(),
491 elapsed_s=f"{elapsed_total:.2f}",
492 ),
493 stats=TourStats(
494 commits=total_commits,
495 branches=total_branches,
496 merges=merge_commits,
497 conflicts_resolved=max(conflicts, 1),
498 operations=_step,
499 ),
500 dag=dag,
501 events=_events,
502 )
503
504 json_path = output_dir / "demo.json"
505 json_path.write_text(json.dumps(tour, indent=2))
506 print(f"✓ JSON → {json_path}")
507
508 if not args.json_only:
509 html_path = output_dir / "demo.html"
510 # render_html is importable because _TOOLS_DIR was added to sys.path above.
511 import render_html as _render_html
512 _render_html.render(tour, html_path)
513 print(f"✓ HTML → {html_path}")
514 print(f"\n Open: file://{html_path.resolve()}")
515
516
517 if __name__ == "__main__":
518 main()