cgcardona / muse public
muse_vcs.md markdown
425 lines 15.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 # Muse VCS — Musical Version Control System
2
3 > **Status:** Canonical Implementation Reference
4 > **E2E demo:** [`muse_e2e_demo.md`](muse_e2e_demo.md)
5
6 ---
7
8 ## What Muse Is
9
10 Muse is a persistent, Git-style version control system for musical compositions. It tracks every committed change as a variation in a DAG (directed acyclic graph), enabling:
11
12 - **Commit history** — every accepted variation is recorded with parent lineage
13 - **Branching** — multiple variations can diverge from the same parent
14 - **Three-way merge** — auto-merges non-conflicting changes, reports conflicts
15 - **Drift detection** — compares HEAD snapshot against the live DAW state (`git status`)
16 - **Checkout / time travel** — reconstruct any historical state via deterministic tool calls
17 - **Log graph** — serialize the full commit DAG as Swift-ready JSON
18
19 ---
20
21 ## Module Map
22
23 ### CLI Entry Point
24
25 ```
26 maestro/muse_cli/
27 ├── __init__.py — Package marker
28 ├── app.py — Typer application root (console script: `muse`)
29 ├── errors.py — Exit-code enum (0 success / 1 user / 2 repo / 3 internal) + exceptions
30 ├── _repo.py — Repository detection (.muse/ directory walker)
31 └── commands/
32 ├── __init__.py
33 ├── init.py — muse init ✅ fully implemented
34 ├── status.py — muse status ✅ branch + commit state display
35 ├── commit.py — muse commit ✅ fully implemented (issue #32)
36 ├── log.py — muse log ✅ fully implemented (issue #33)
37 ├── checkout.py — muse checkout ✅ fully implemented (issue #34)
38 ├── snapshot.py — walk_workdir, hash_file, build_snapshot_manifest, compute IDs
39 ├── models.py — MuseCliCommit, MuseCliSnapshot, MuseCliObject (SQLAlchemy)
40 ├── db.py — open_session, upsert_object/snapshot/commit helpers
41 ├── merge.py — muse merge (stub — issue #35)
42 ├── remote.py — muse remote (stub — issue #38)
43 ├── push.py — muse push (stub — issue #38)
44 └── pull.py — muse pull (stub — issue #38)
45 ```
46
47 The CLI delegates to existing `maestro/services/muse_*.py` service modules. Stub subcommands print "not yet implemented" and exit 0.
48
49 ---
50
51 ## `muse log` Output Formats
52
53 ### Default (`git log` style)
54
55 ```
56 commit a1b2c3d4e5f6... (HEAD -> main)
57 Parent: f9e8d7c6
58 Date: 2026-02-27 17:30:00
59
60 boom bap demo take 1
61
62 commit f9e8d7c6...
63 Date: 2026-02-27 17:00:00
64
65 initial take
66 ```
67
68 Commits are printed newest-first. The first commit (root) has no `Parent:` line.
69
70 ### `--graph` mode
71
72 Reuses `maestro.services.muse_log_render.render_ascii_graph` by adapting `MuseCliCommit` rows to the `MuseLogGraph`/`MuseLogNode` dataclasses the renderer expects.
73
74 ```
75 * a1b2c3d4 boom bap demo take 1 (HEAD)
76 * f9e8d7c6 initial take
77 ```
78
79 Merge commits (two parents) require `muse merge` (issue #35) — `parent2_commit_id` is reserved for that iteration.
80
81 ### Flags
82
83 | Flag | Default | Description |
84 |------|---------|-------------|
85 | `--limit N` / `-n N` | 1000 | Cap the walk at N commits |
86 | `--graph` | off | ASCII DAG mode |
87
88 ---
89
90 ## Branching Model
91
92 ### `muse checkout` — branch creation and HEAD pointer management
93
94 Branches are tracked as files under `.muse/refs/heads/<branch-name>`, each containing the `commit_id` of the branch tip (the same convention as Git's packed-refs but in plain files).
95
96 `.muse/HEAD` holds the symbolic ref of the currently active branch:
97
98 ```
99 refs/heads/main
100 ```
101
102 ### Switching branches
103
104 `muse checkout <branch>` rewrites `.muse/HEAD` to `refs/heads/<branch>`. Subsequent `muse commit` and `muse log` calls read this file to know which branch to operate on.
105
106 ### Creating branches
107
108 `muse checkout -b <branch>` forks from the current HEAD commit:
109
110 1. Reads the current branch tip from `.muse/refs/heads/<current>`.
111 2. Writes that same `commit_id` to `.muse/refs/heads/<new-branch>`.
112 3. Rewrites `.muse/HEAD` to `refs/heads/<new-branch>`.
113
114 The new branch starts with the same history as its parent — divergence happens on the next `muse commit`.
115
116 ### Dirty working-tree guard
117
118 Before switching branches, `muse checkout` compares the on-disk `snapshot_id` of `muse-work/` against the last committed snapshot on the **current** branch. If they differ the command exits `1` with a message. Use `--force` / `-f` to bypass the guard.
119
120 If the current branch has no commits yet (empty branch) the tree is never considered dirty.
121
122 ### Flags
123
124 | Flag | Description |
125 |------|-------------|
126 | `-b` / `--create` | Create a new branch at current HEAD and switch to it |
127 | `--force` / `-f` | Ignore uncommitted changes in `muse-work/` |
128
129 ### DB-level branch table
130
131 A `muse_cli_branches` Postgres table is deferred to the `muse merge` iteration (issue #35), when multi-branch DAG queries will require stable foreign-key references. Until then, branches live exclusively in `.muse/refs/heads/`.
132
133 ---
134
135 ## Commit Data Model
136
137 `muse commit` persists three content-addressed table types to Postgres:
138
139 ### `muse_cli_objects` — File blobs (sha256-keyed)
140
141 | Column | Type | Description |
142 |--------|------|-------------|
143 | `object_id` | `String(64)` PK | `sha256(file_bytes)` hex digest |
144 | `size_bytes` | `Integer` | Raw file size |
145 | `created_at` | `DateTime(tz=True)` | Wall-clock insert time |
146
147 Objects are deduplicated across commits: the same file committed on two branches is stored exactly once.
148
149 ### `muse_cli_snapshots` — Snapshot manifests
150
151 | Column | Type | Description |
152 |--------|------|-------------|
153 | `snapshot_id` | `String(64)` PK | `sha256(sorted("path:object_id" pairs))` |
154 | `manifest` | `JSON` | `{rel_path: object_id}` mapping |
155 | `created_at` | `DateTime(tz=True)` | Wall-clock insert time |
156
157 Two identical working trees always produce the same `snapshot_id`.
158
159 ### `muse_cli_commits` — Commit history
160
161 | Column | Type | Description |
162 |--------|------|-------------|
163 | `commit_id` | `String(64)` PK | Deterministic sha256 (see below) |
164 | `repo_id` | `String(36)` | UUID from `.muse/repo.json` |
165 | `branch` | `String(255)` | Branch name at commit time |
166 | `parent_commit_id` | `String(64)` nullable | Previous HEAD commit on branch |
167 | `snapshot_id` | `String(64)` FK | Points to the snapshot row |
168 | `message` | `Text` | User-supplied commit message |
169 | `author` | `String(255)` | Reserved (empty for MVP) |
170 | `committed_at` | `DateTime(tz=True)` | Timestamp used in hash derivation |
171 | `created_at` | `DateTime(tz=True)` | Wall-clock DB insert time |
172
173 ### ID Derivation (deterministic)
174
175 ```
176 object_id = sha256(file_bytes)
177 snapshot_id = sha256("|".join(sorted(f"{path}:{oid}" for path, oid in manifest.items())))
178 commit_id = sha256(
179 "|".join(sorted(parent_ids))
180 + "|" + snapshot_id
181 + "|" + message
182 + "|" + committed_at.isoformat()
183 )
184 ```
185
186 Given the same working tree state, message, and timestamp two machines produce identical IDs. `sorted()` ensures insertion-order independence for both snapshot manifests and parent lists.
187
188 ---
189
190 ## Local Repository Structure (`.muse/`)
191
192 `muse init` creates the following layout in the current working directory:
193
194 ```
195 .muse/
196 repo.json Repo identity: repo_id (UUID), schema_version, created_at
197 HEAD Current branch pointer, e.g. "refs/heads/main"
198 config.toml [user], [auth], [remotes] configuration
199 refs/
200 heads/
201 main Commit ID of branch HEAD (empty = no commits yet)
202 <branch> One file per branch
203 ```
204
205 ### File semantics
206
207 | File | Source of truth for | Notes |
208 |------|-------------------|-------|
209 | `repo.json` | Repo identity | `repo_id` persists across `--force` reinitialise |
210 | `HEAD` | Current branch name | Always `refs/heads/<branch>` |
211 | `refs/heads/<branch>` | Branch → commit pointer | Empty string = branch has no commits yet |
212 | `config.toml` | User identity, auth token, remotes | Not overwritten on `--force` |
213
214 ### Repo-root detection
215
216 Every CLI command locates the active repo by walking up the directory tree until `.muse/` is found:
217
218 ```python
219 # maestro/muse_cli/_repo.py
220 find_repo_root(start: Path | None = None) -> Path | None
221 ```
222
223 - Returns the directory containing `.muse/`, or `None` if not found (never raises).
224 - Set `MUSE_REPO_ROOT=/path/to/repo` to override traversal (useful in tests and scripts).
225 - `require_repo()` wraps `find_repo_root()` for command callbacks: exits 2 with "Not a Muse repository. Run `muse init`." if root is `None`.
226
227 ### `config.toml` example
228
229 ```toml
230 [user]
231 name = "Gabriel"
232 email = "g@example.com"
233
234 [auth]
235 token = "eyJ..." # Muse Hub Bearer token — keep out of version control
236
237 [remotes]
238 [remotes.origin]
239 url = "https://story.audio/musehub/repos/abcd1234"
240 ```
241
242 > **Security note:** `.muse/config.toml` contains the Hub auth token. Add `.muse/config.toml` to `.gitignore` (or `.museignore`) to prevent accidental exposure.
243
244 ### VCS Services
245
246 ```
247 app/services/
248 ├── muse_repository.py — Persistence adapter (DB reads/writes)
249 ├── muse_replay.py — History reconstruction (lineage walking)
250 ├── muse_drift.py — Drift detection engine (HEAD vs working)
251 ├── muse_checkout.py — Checkout plan builder (pure data → tool calls)
252 ├── muse_checkout_executor.py — Checkout execution (applies plan to StateStore)
253 ├── muse_merge_base.py — Merge base finder (LCA in the DAG)
254 ├── muse_merge.py — Three-way merge engine
255 ├── muse_history_controller.py— Orchestrates checkout + merge flows
256 ├── muse_log_graph.py — DAG serializer (topological sort → JSON)
257 ├── muse_log_render.py — ASCII graph + JSON + summary renderer
258 └── variation/
259 └── note_matching.py — Note + controller event matching/diffing
260
261 app/api/routes/
262 ├── muse.py — Production HTTP routes (5 endpoints)
263 └── variation/ — Existing variation proposal routes
264
265 app/db/
266 └── muse_models.py — ORM: Variation, Phrase, NoteChange tables
267
268 tests/
269 ├── test_muse_persistence.py — Repository + lineage tests
270 ├── test_muse_drift.py — Drift detection tests
271 ├── test_muse_drift_controllers.py — Controller drift tests
272 ├── test_commit_drift_safety.py — 409 conflict enforcement
273 ├── test_muse_checkout.py — Checkout plan tests
274 ├── test_muse_checkout_execution.py — Checkout execution tests
275 ├── test_muse_merge.py — Merge engine tests
276 ├── test_muse_log_graph.py — Log graph serialization tests
277 └── e2e/
278 ├── muse_fixtures.py — Deterministic IDs + snapshot builders
279 └── test_muse_e2e_harness.py — Full VCS lifecycle E2E test
280 ```
281
282 ---
283
284 ## Data Model
285
286 ### Variation (ORM: `app/db/muse_models.py`)
287
288 | Column | Type | Purpose |
289 |--------|------|---------|
290 | `variation_id` | PK | Unique ID |
291 | `project_id` | FK | Project this belongs to |
292 | `parent_variation_id` | FK (self) | Primary parent (lineage) |
293 | `parent2_variation_id` | FK (self) | Second parent (merge commits only) |
294 | `is_head` | bool | Whether this is the current HEAD |
295 | `commit_state_id` | str | State version at commit time |
296 | `intent` | text | User intent / description |
297 | `status` | str | `ready` / `committed` / `discarded` |
298
299 ### HeadSnapshot (`app/services/muse_replay.py`)
300
301 Reconstructed from walking the variation lineage. Contains the cumulative state at any point in history:
302
303 | Field | Type | Contents |
304 |-------|------|----------|
305 | `notes` | `dict[region_id, list[note_dict]]` | All notes per region |
306 | `cc` | `dict[region_id, list[cc_event]]` | CC events per region |
307 | `pitch_bends` | `dict[region_id, list[pb_event]]` | Pitch bends per region |
308 | `aftertouch` | `dict[region_id, list[at_event]]` | Aftertouch per region |
309 | `track_regions` | `dict[region_id, track_id]` | Region-to-track mapping |
310
311 ---
312
313 ## HTTP API
314
315 All routes require JWT auth (`Authorization: Bearer <token>`).
316 Prefix: `/api/v1/muse/`
317
318 | Method | Path | Purpose |
319 |--------|------|---------|
320 | `POST` | `/muse/variations` | Save a variation directly into history |
321 | `POST` | `/muse/head` | Set HEAD pointer to a variation |
322 | `GET` | `/muse/log?project_id=X` | Get the full commit DAG as `MuseLogGraph` JSON |
323 | `POST` | `/muse/checkout` | Checkout to a variation (time travel) |
324 | `POST` | `/muse/merge` | Three-way merge of two variations |
325
326 ### Response codes
327
328 | Code | Meaning |
329 |------|---------|
330 | 200 | Success |
331 | 404 | Variation not found (checkout) |
332 | 409 | Checkout blocked by drift / merge has conflicts |
333
334 ---
335
336 ## VCS Primitives
337
338 ### Commit (save + set HEAD)
339
340 ```
341 save_variation(session, variation, project_id, parent_variation_id, ...)
342 set_head(session, variation_id)
343 ```
344
345 ### Lineage
346
347 ```
348 get_lineage(session, variation_id) → [root, ..., target]
349 get_head(session, project_id) → HistoryNode | None
350 get_children(session, variation_id) → [HistoryNode, ...]
351 ```
352
353 ### Drift Detection
354
355 ```
356 compute_drift_report(head_snapshot, working_snapshot, ...) → DriftReport
357 ```
358
359 Compares HEAD (from DB) against working state (from StateStore). Severity levels: `CLEAN`, `DIRTY`, `DIVERGED`.
360
361 ### Replay / Reconstruction
362
363 ```
364 reconstruct_head_snapshot(session, project_id) → HeadSnapshot
365 reconstruct_variation_snapshot(session, variation_id) → HeadSnapshot
366 build_replay_plan(session, project_id, target_id) → ReplayPlan
367 ```
368
369 ### Checkout
370
371 ```
372 build_checkout_plan(target_notes, working_notes, ...) → CheckoutPlan
373 execute_checkout_plan(plan, store, trace) → CheckoutExecutionResult
374 checkout_to_variation(session, project_id, target_id, store, ...) → CheckoutSummary
375 ```
376
377 ### Merge
378
379 ```
380 find_merge_base(session, a, b) → str | None
381 build_merge_result(base, left, right) → MergeResult
382 merge_variations(session, project_id, left, right, store, ...) → MergeSummary
383 ```
384
385 ### Log Graph
386
387 ```
388 build_muse_log_graph(session, project_id) → MuseLogGraph
389 ```
390
391 Topologically sorted (Kahn's algorithm), deterministic tie-breaking by `(timestamp, variation_id)`. Output is camelCase JSON for the Swift frontend.
392
393 ---
394
395 ## Architectural Boundaries
396
397 17 AST-enforced rules in `scripts/check_boundaries.py`. Key constraints:
398
399 | Module | Must NOT import |
400 |--------|----------------|
401 | `muse_repository` | StateStore, executor, VariationService |
402 | `muse_replay` | StateStore, executor, LLM handlers |
403 | `muse_drift` | StateStore, executor, LLM handlers |
404 | `muse_checkout` | StateStore, executor, handlers |
405 | `muse_checkout_executor` | LLM handlers, VariationService |
406 | `muse_merge`, `muse_merge_base` | StateStore, executor, MCP, handlers |
407 | `muse_log_graph` | StateStore, executor, handlers, engines |
408 | `note_matching` | handlers, StateStore |
409
410 The boundary philosophy: Muse VCS modules are **pure data** — they consume snapshots and produce plans/reports. StateStore mutation only happens in `muse_checkout_executor` (via duck-typed store parameter) and the history controller.
411
412 ---
413
414 ## E2E Demo
415
416 Run the full VCS lifecycle test:
417
418 ```bash
419 docker compose exec maestro pytest tests/e2e/test_muse_e2e_harness.py -v -s
420 ```
421
422 Exercises: commit → branch → merge → conflict detection → checkout traversal.
423 Produces: ASCII graph, JSON dump, summary table. See `muse_e2e_demo.md` for details.
424
425 ---