gabriel / muse public

demo.py file-level

at sha256:a · View file ↗ · Intel ↗

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