muse_log_graph.py
python
| 1 | """Muse Log Graph — serialize the commit DAG as Swift-ready JSON. |
| 2 | |
| 3 | Pure read-only projection layer. Fetches all variations for a project |
| 4 | in a single query, builds the DAG in memory, and performs a stable |
| 5 | topological sort. O(N + E) time complexity. |
| 6 | |
| 7 | Boundary rules: |
| 8 | - Must NOT import StateStore, executor, handlers, LLM code, |
| 9 | drift engine, merge engine, or checkout modules. |
| 10 | - May import muse_repository (for bulk queries). |
| 11 | """ |
| 12 | |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import heapq |
| 16 | import logging |
| 17 | from collections import defaultdict |
| 18 | from dataclasses import dataclass |
| 19 | from sqlalchemy.ext.asyncio import AsyncSession |
| 20 | |
| 21 | from pydantic import BaseModel, Field |
| 22 | |
| 23 | from maestro.services.muse_repository import VariationSummary, get_variations_for_project |
| 24 | |
| 25 | logger = logging.getLogger(__name__) |
| 26 | |
| 27 | |
| 28 | # ── Pydantic response models ────────────────────────────────────────────────── |
| 29 | |
| 30 | |
| 31 | class MuseLogNodeResponse(BaseModel): |
| 32 | """Wire representation of a single node in the Muse commit DAG. |
| 33 | |
| 34 | Produced by ``MuseLogNode.to_response()`` and serialised as JSON by the |
| 35 | ``GET /muse/log`` endpoint. Field names are camelCase to match the Stori |
| 36 | DAW Swift convention. |
| 37 | |
| 38 | Attributes: |
| 39 | id: UUID of this variation (commit). |
| 40 | parent: UUID of the first (or only) parent variation, or ``None`` for |
| 41 | the root commit. |
| 42 | parent2: UUID of the second parent for merge commits; ``None`` for |
| 43 | ordinary (linear) commits. |
| 44 | isHead: ``True`` if this node is the current HEAD pointer for the |
| 45 | project (i.e. the variation the DAW currently has loaded). |
| 46 | timestamp: POSIX timestamp (seconds since epoch, float) of when the |
| 47 | variation was committed. Used for topological sort tie-breaking |
| 48 | and for display in the DAW's history panel. |
| 49 | intent: Free-text intent string supplied when the variation was created |
| 50 | (e.g. ``"add bass groove"``), or ``None`` if none was provided. |
| 51 | regions: List of region IDs whose MIDI content was affected by this |
| 52 | variation. Empty for root / no-op commits. |
| 53 | """ |
| 54 | |
| 55 | id: str = Field(description="UUID of this variation (commit).") |
| 56 | parent: str | None = Field( |
| 57 | description="UUID of the first parent variation, or None for the root commit." |
| 58 | ) |
| 59 | parent2: str | None = Field( |
| 60 | description=( |
| 61 | "UUID of the second parent for merge commits; " |
| 62 | "None for ordinary (linear) commits." |
| 63 | ) |
| 64 | ) |
| 65 | isHead: bool = Field( |
| 66 | description=( |
| 67 | "True if this node is the current HEAD pointer" |
| 68 | "the variation the DAW currently has loaded." |
| 69 | ) |
| 70 | ) |
| 71 | timestamp: float = Field( |
| 72 | description=( |
| 73 | "POSIX timestamp (seconds since epoch) of when the variation was committed. " |
| 74 | "Used for topological sort tie-breaking and history-panel display." |
| 75 | ) |
| 76 | ) |
| 77 | intent: str | None = Field( |
| 78 | description=( |
| 79 | "Free-text intent string supplied at commit time " |
| 80 | "(e.g. 'add bass groove'), or None if none was provided." |
| 81 | ) |
| 82 | ) |
| 83 | regions: list[str] = Field( |
| 84 | description=( |
| 85 | "List of region IDs whose MIDI content was affected by this variation. " |
| 86 | "Empty for root or no-op commits." |
| 87 | ) |
| 88 | ) |
| 89 | |
| 90 | |
| 91 | class MuseLogGraphResponse(BaseModel): |
| 92 | """Wire representation of the full Muse commit DAG for a project. |
| 93 | |
| 94 | Produced by ``MuseLogGraph.to_response()`` and returned directly by |
| 95 | ``GET /muse/log``. The Swift frontend renders this as a visual commit |
| 96 | timeline, highlighting the HEAD node and drawing parent edges. |
| 97 | |
| 98 | Attributes: |
| 99 | projectId: UUID of the project this DAG belongs to. |
| 100 | head: UUID of the current HEAD variation, or ``None`` if no HEAD has |
| 101 | been set yet (brand-new project with no commits). |
| 102 | nodes: Topologically sorted list of all variations (commits) for this |
| 103 | project. Parents always appear before their children; ties are |
| 104 | broken by timestamp then variation UUID. |
| 105 | """ |
| 106 | |
| 107 | projectId: str = Field( |
| 108 | description="UUID of the project this DAG belongs to." |
| 109 | ) |
| 110 | head: str | None = Field( |
| 111 | description=( |
| 112 | "UUID of the current HEAD variation, or None if no HEAD has been set yet " |
| 113 | "(brand-new project with no commits)." |
| 114 | ) |
| 115 | ) |
| 116 | nodes: list[MuseLogNodeResponse] = Field( |
| 117 | description=( |
| 118 | "Topologically sorted list of all variations (commits) for this project. " |
| 119 | "Parents always appear before their children; ties broken by timestamp then UUID." |
| 120 | ) |
| 121 | ) |
| 122 | |
| 123 | |
| 124 | # ── Internal domain dataclasses ─────────────────────────────────────────────── |
| 125 | |
| 126 | |
| 127 | @dataclass(frozen=True) |
| 128 | class MuseLogNode: |
| 129 | """A single node in the commit DAG.""" |
| 130 | |
| 131 | variation_id: str |
| 132 | parent: str | None |
| 133 | parent2: str | None |
| 134 | is_head: bool |
| 135 | timestamp: float |
| 136 | intent: str | None |
| 137 | affected_regions: tuple[str, ...] |
| 138 | |
| 139 | def to_response(self) -> MuseLogNodeResponse: |
| 140 | """Convert this internal node to its Pydantic wire representation. |
| 141 | |
| 142 | Translates snake_case internal field names to the camelCase names |
| 143 | expected by the Stori DAW frontend, and converts the immutable |
| 144 | ``affected_regions`` tuple to a plain list. |
| 145 | |
| 146 | Returns: |
| 147 | ``MuseLogNodeResponse`` ready for JSON serialisation by FastAPI. |
| 148 | """ |
| 149 | return MuseLogNodeResponse( |
| 150 | id=self.variation_id, |
| 151 | parent=self.parent, |
| 152 | parent2=self.parent2, |
| 153 | isHead=self.is_head, |
| 154 | timestamp=self.timestamp, |
| 155 | intent=self.intent, |
| 156 | regions=list(self.affected_regions), |
| 157 | ) |
| 158 | |
| 159 | |
| 160 | @dataclass(frozen=True) |
| 161 | class MuseLogGraph: |
| 162 | """The full commit DAG for a project, topologically ordered.""" |
| 163 | |
| 164 | project_id: str |
| 165 | head: str | None |
| 166 | nodes: tuple[MuseLogNode, ...] |
| 167 | |
| 168 | def to_response(self) -> MuseLogGraphResponse: |
| 169 | """Convert this internal graph to its Pydantic wire representation. |
| 170 | |
| 171 | Calls ``MuseLogNode.to_response()`` on every node in topological order |
| 172 | and wraps them in a ``MuseLogGraphResponse``. |
| 173 | |
| 174 | Returns: |
| 175 | ``MuseLogGraphResponse`` ready for JSON serialisation by FastAPI. |
| 176 | """ |
| 177 | return MuseLogGraphResponse( |
| 178 | projectId=self.project_id, |
| 179 | head=self.head, |
| 180 | nodes=[n.to_response() for n in self.nodes], |
| 181 | ) |
| 182 | |
| 183 | |
| 184 | async def build_muse_log_graph( |
| 185 | session: AsyncSession, |
| 186 | project_id: str, |
| 187 | ) -> MuseLogGraph: |
| 188 | """Build the full commit DAG for a project. |
| 189 | |
| 190 | Performs a single bulk query, then computes the topological ordering |
| 191 | in memory. Parents always appear before children; ties are broken |
| 192 | by timestamp (earliest first), then by variation_id for determinism. |
| 193 | """ |
| 194 | summaries = await get_variations_for_project(session, project_id) |
| 195 | |
| 196 | if not summaries: |
| 197 | return MuseLogGraph(project_id=project_id, head=None, nodes=()) |
| 198 | |
| 199 | nodes = _build_nodes(summaries) |
| 200 | sorted_nodes = _topological_sort(nodes) |
| 201 | head_id = _find_head(summaries) |
| 202 | |
| 203 | logger.info( |
| 204 | "✅ Log graph built: project=%s, %d nodes, head=%s", |
| 205 | project_id[:8], len(sorted_nodes), (head_id or "none")[:8], |
| 206 | ) |
| 207 | |
| 208 | return MuseLogGraph( |
| 209 | project_id=project_id, |
| 210 | head=head_id, |
| 211 | nodes=tuple(sorted_nodes), |
| 212 | ) |
| 213 | |
| 214 | |
| 215 | def _build_nodes(summaries: list[VariationSummary]) -> list[MuseLogNode]: |
| 216 | """Convert VariationSummary rows into MuseLogNode instances.""" |
| 217 | return [ |
| 218 | MuseLogNode( |
| 219 | variation_id=s.variation_id, |
| 220 | parent=s.parent_variation_id, |
| 221 | parent2=s.parent2_variation_id, |
| 222 | is_head=s.is_head, |
| 223 | timestamp=s.created_at.timestamp(), |
| 224 | intent=s.intent if s.intent else None, |
| 225 | affected_regions=s.affected_regions, |
| 226 | ) |
| 227 | for s in summaries |
| 228 | ] |
| 229 | |
| 230 | |
| 231 | def _find_head(summaries: list[VariationSummary]) -> str | None: |
| 232 | """Return the HEAD variation_id, or None if no HEAD is set.""" |
| 233 | for s in summaries: |
| 234 | if s.is_head: |
| 235 | return s.variation_id |
| 236 | return None |
| 237 | |
| 238 | |
| 239 | def _topological_sort(nodes: list[MuseLogNode]) -> list[MuseLogNode]: |
| 240 | """Stable topological sort via Kahn's algorithm. |
| 241 | |
| 242 | Ordering guarantees: |
| 243 | 1. Parents always appear before children. |
| 244 | 2. Tie-break by timestamp (earliest first). |
| 245 | 3. Final tie-break by variation_id (lexicographic). |
| 246 | """ |
| 247 | by_id: dict[str, MuseLogNode] = {n.variation_id: n for n in nodes} |
| 248 | known_ids = set(by_id.keys()) |
| 249 | |
| 250 | children: dict[str, list[str]] = defaultdict(list) |
| 251 | in_degree: dict[str, int] = {n.variation_id: 0 for n in nodes} |
| 252 | |
| 253 | for node in nodes: |
| 254 | if node.parent and node.parent in known_ids: |
| 255 | children[node.parent].append(node.variation_id) |
| 256 | in_degree[node.variation_id] += 1 |
| 257 | if node.parent2 and node.parent2 in known_ids: |
| 258 | children[node.parent2].append(node.variation_id) |
| 259 | in_degree[node.variation_id] += 1 |
| 260 | |
| 261 | heap: list[tuple[float, str]] = [] |
| 262 | for vid, deg in in_degree.items(): |
| 263 | if deg == 0: |
| 264 | n = by_id[vid] |
| 265 | heapq.heappush(heap, (n.timestamp, vid)) |
| 266 | |
| 267 | result: list[MuseLogNode] = [] |
| 268 | |
| 269 | while heap: |
| 270 | _, vid = heapq.heappop(heap) |
| 271 | result.append(by_id[vid]) |
| 272 | |
| 273 | for child_id in children[vid]: |
| 274 | in_degree[child_id] -= 1 |
| 275 | if in_degree[child_id] == 0: |
| 276 | child = by_id[child_id] |
| 277 | heapq.heappush(heap, (child.timestamp, child_id)) |
| 278 | |
| 279 | return result |