gabriel / muse public
demo.py python
518 lines 16.6 KB
Raw
sha256:a73c3f57b665e8c0be2c9e977b3ebefdb7ae8d46f196986d911c6a8f5d8b8d49 docs: update store.py references to focused module paths Sonnet 4.6 28 days 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
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 .muse/commits/ and construct the full DAG."""
378 commits_dir = root / ".muse" / "commits"
379 raw: list[CommitNode] = []
380
381 if commits_dir.exists():
382 for f in commits_dir.glob("*.json"):
383 data: dict[str, object] = load_json_file(f) or {}
384 if not data:
385 continue
386
387 parents: list[str] = []
388 p1 = data.get("parent_commit_id")
389 p2 = data.get("parent2_commit_id")
390 if isinstance(p1, str) and p1:
391 parents.append(p1)
392 if isinstance(p2, str) and p2:
393 parents.append(p2)
394
395 files: list[str] = []
396 snap_id = data.get("snapshot_id", "")
397 if isinstance(snap_id, str):
398 snap_file = root / ".muse" / "snapshots" / f"{snap_id}.json"
399 if snap_file.exists():
400 snap = load_json_file(snap_file) or {}
401 files = sorted(snap.get("manifest", {}).keys())
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=now_utc_iso(),
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()
File History 2 commits
sha256:a73c3f57b665e8c0be2c9e977b3ebefdb7ae8d46f196986d911c6a8f5d8b8d49 docs: update store.py references to focused module paths Sonnet 4.6 28 days ago
sha256:b6cae4448122b2cc690d913be26f7e0a539f11855b8d288bd48be43eb532b5b2 refactor: migrate all source callers off muse.core.store re… Sonnet 4.6 minor 28 days ago