cgcardona / muse public
release.py python
369 lines 12.6 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse release <tag> — export and render a tagged commit as a release artifact.
2
3 This is the music-native publish step: given a tag applied via ``muse tag add``,
4 it resolves the tagged commit, fetches its MIDI snapshot, and renders it to
5 audio/MIDI artifacts ready for distribution.
6
7 Usage::
8
9 muse release v1.0 # manifest only (dry run)
10 muse release v1.0 --render-audio # single WAV file
11 muse release v1.0 --render-midi # zip of all MIDI files
12 muse release v1.0 --export-stems --format flac # per-track FLAC stems
13 muse release v1.0 --render-audio --render-midi \\
14 --output-dir ./dist/v1.0 # custom output dir
15
16 Flags:
17 <tag> Music-semantic tag created via ``muse tag add``.
18 --render-audio Render all MIDI to a single audio file via Storpheus.
19 --render-midi Bundle all .mid files into a zip archive.
20 --export-stems Export each track as a separate audio file.
21 --format Audio output format: wav | mp3 | flac (default: wav).
22 --output-dir PATH Where to write artifacts (default: ./releases/<tag>/).
23 --json Emit structured JSON output for agent consumption.
24
25 Output layout::
26
27 <output-dir>/
28 release-manifest.json # always written; SHA-256 checksums
29 audio/<commit8>.<format> # --render-audio
30 midi/midi-bundle.zip # --render-midi
31 stems/<stem>.<format> # --export-stems
32
33 This command resolves the tag via the Muse tag database (``muse tag add``).
34 If multiple commits share the same tag the most recently committed one is used.
35 """
36 from __future__ import annotations
37
38 import asyncio
39 import json as json_mod
40 import logging
41 import pathlib
42 from typing import Optional
43
44 import typer
45 from sqlalchemy import select
46 from sqlalchemy.ext.asyncio import AsyncSession
47
48 from maestro.config import settings
49 from maestro.muse_cli._repo import require_repo
50 from maestro.muse_cli.db import find_commits_by_prefix, get_commit_snapshot_manifest, open_session
51 from maestro.muse_cli.errors import ExitCode
52 from maestro.muse_cli.models import MuseCliCommit, MuseCliTag
53 from maestro.services.muse_release import (
54 ReleaseAudioFormat,
55 ReleaseResult,
56 StorpheusReleaseUnavailableError,
57 build_release,
58 )
59
60 logger = logging.getLogger(__name__)
61
62 app = typer.Typer()
63
64 _DEFAULT_RELEASES_DIR = "releases"
65
66
67 def _default_output_dir(tag: str) -> pathlib.Path:
68 """Return the default output directory for a release.
69
70 Pattern: ``./releases/<tag>/``. Safe for any tag string that is a valid
71 directory name — callers should sanitise the tag before passing here.
72
73 Args:
74 tag: Release tag string (e.g. ``"v1.0"``).
75
76 Returns:
77 A Path relative to the current working directory.
78 """
79 safe_tag = tag.replace("/", "_").replace("\\", "_")
80 return pathlib.Path(_DEFAULT_RELEASES_DIR) / safe_tag
81
82
83 async def _resolve_tag_to_commit(
84 session: AsyncSession,
85 root: pathlib.Path,
86 tag: str,
87 ) -> str:
88 """Resolve a music-semantic tag string to a full commit ID.
89
90 Queries the ``muse_cli_tags`` table for commits carrying *tag*. When
91 multiple commits share the tag, the most recently committed one is returned
92 — this matches the producer's expectation that ``v1.0`` refers to the
93 latest commit labelled with that tag.
94
95 Falls back to prefix-based commit lookup when no tag record is found, so
96 producers can also pass a raw commit SHA prefix directly.
97
98 Args:
99 session: Open async DB session.
100 root: Muse repository root (used to read repo.json).
101 tag: Tag string (e.g. ``"v1.0"``) or short commit SHA prefix.
102
103 Returns:
104 Full 64-character commit ID.
105
106 Raises:
107 typer.Exit: With ``USER_ERROR`` when the tag/prefix cannot be resolved.
108 """
109 import json
110
111 # Read repo_id for scoped tag lookup.
112 repo_json = root / ".muse" / "repo.json"
113 repo_id: str | None = None
114 if repo_json.exists():
115 data: dict[str, str] = json.loads(repo_json.read_text())
116 repo_id = data.get("repo_id")
117
118 # 1. Tag-based lookup (join MuseCliTag → MuseCliCommit).
119 if repo_id is not None:
120 tag_result = await session.execute(
121 select(MuseCliTag.commit_id)
122 .where(MuseCliTag.repo_id == repo_id, MuseCliTag.tag == tag)
123 )
124 tag_commit_ids: list[str] = list(tag_result.scalars().all())
125
126 if tag_commit_ids:
127 if len(tag_commit_ids) == 1:
128 return tag_commit_ids[0]
129
130 # Multiple commits share the tag — return the most recently committed.
131 commits_result = await session.execute(
132 select(MuseCliCommit)
133 .where(MuseCliCommit.commit_id.in_(tag_commit_ids))
134 .order_by(MuseCliCommit.committed_at.desc())
135 .limit(1)
136 )
137 latest = commits_result.scalar_one_or_none()
138 if latest is not None:
139 logger.warning(
140 "⚠️ release: tag %r exists on %d commits — using most recent: %s",
141 tag,
142 len(tag_commit_ids),
143 latest.commit_id[:8],
144 )
145 return latest.commit_id
146
147 # 2. Prefix-based fallback — treat <tag> as a short commit SHA.
148 prefix_matches = await find_commits_by_prefix(session, tag)
149 if len(prefix_matches) == 1:
150 return prefix_matches[0].commit_id
151 if len(prefix_matches) > 1:
152 typer.echo(
153 f"❌ Ambiguous commit prefix '{tag[:8]}' "
154 f"— matches {len(prefix_matches)} commits:"
155 )
156 for c in prefix_matches:
157 typer.echo(f" {c.commit_id[:8]} {c.message[:60]}")
158 typer.echo("Use a longer prefix or an exact tag string to disambiguate.")
159 raise typer.Exit(code=ExitCode.USER_ERROR)
160
161 typer.echo(
162 f"❌ No commit found for tag or prefix '{tag}'. "
163 "Create the tag first: muse tag add <tag> [<commit>]"
164 )
165 raise typer.Exit(code=ExitCode.USER_ERROR)
166
167
168 async def _release_async(
169 *,
170 tag: str,
171 audio_format: ReleaseAudioFormat,
172 output_dir: Optional[pathlib.Path],
173 render_audio: bool,
174 render_midi: bool,
175 export_stems: bool,
176 root: pathlib.Path,
177 session: AsyncSession,
178 ) -> ReleaseResult:
179 """Core release logic — injectable for tests.
180
181 Resolves the tag to a commit ID, loads the snapshot manifest, and
182 delegates to ``build_release`` in the service layer.
183
184 Args:
185 tag: Tag string or short commit SHA prefix.
186 audio_format: Target audio format for rendered files.
187 output_dir: Explicit output directory or None for the default path.
188 render_audio: Whether to render the primary MIDI to an audio file.
189 render_midi: Whether to bundle all MIDI files into a zip archive.
190 export_stems: Whether to export each MIDI track as a separate audio file.
191 root: Muse repository root.
192 session: Open async DB session.
193
194 Returns:
195 ReleaseResult describing what was written.
196
197 Raises:
198 typer.Exit: On user errors (missing tag, empty snapshot, etc.).
199 StorpheusReleaseUnavailableError: When audio render is requested and
200 Storpheus is unreachable.
201 """
202 full_commit_id = await _resolve_tag_to_commit(session, root, tag)
203
204 manifest = await get_commit_snapshot_manifest(session, full_commit_id)
205 if manifest is None:
206 typer.echo(f"❌ Commit {full_commit_id[:8]} not found in database.")
207 raise typer.Exit(code=ExitCode.USER_ERROR)
208
209 if not manifest:
210 typer.echo(
211 f"⚠️ Snapshot for commit {full_commit_id[:8]} is empty — nothing to release."
212 )
213 raise typer.Exit(code=ExitCode.USER_ERROR)
214
215 out_dir = output_dir if output_dir is not None else _default_output_dir(tag)
216 storpheus_url = settings.storpheus_base_url
217
218 return build_release(
219 tag=tag,
220 commit_id=full_commit_id,
221 manifest=manifest,
222 root=root,
223 output_dir=out_dir,
224 audio_format=audio_format,
225 render_audio=render_audio,
226 render_midi=render_midi,
227 export_stems=export_stems,
228 storpheus_url=storpheus_url,
229 )
230
231
232 @app.callback(invoke_without_command=True)
233 def release(
234 ctx: typer.Context,
235 tag: str = typer.Argument(
236 ...,
237 help=(
238 "Tag or commit prefix to release (e.g. v1.0). "
239 "Tags are created via 'muse tag add <tag>'."
240 ),
241 ),
242 render_audio: bool = typer.Option(
243 False,
244 "--render-audio",
245 help="Render all MIDI snapshots to a single audio file via Storpheus.",
246 ),
247 render_midi: bool = typer.Option(
248 False,
249 "--render-midi",
250 help="Bundle all .mid files from the snapshot into a zip archive.",
251 ),
252 export_stems: bool = typer.Option(
253 False,
254 "--export-stems",
255 help="Export each instrument track as a separate audio file.",
256 ),
257 audio_format: ReleaseAudioFormat = typer.Option(
258 ReleaseAudioFormat.WAV,
259 "--format",
260 "-f",
261 help="Audio output format: wav | mp3 | flac (default: wav).",
262 case_sensitive=False,
263 ),
264 output_dir: Optional[pathlib.Path] = typer.Option(
265 None,
266 "--output-dir",
267 "-o",
268 help="Where to write release artifacts (default: ./releases/<tag>/).",
269 ),
270 as_json: bool = typer.Option(
271 False,
272 "--json",
273 help="Emit structured JSON output for agent consumption.",
274 ),
275 ) -> None:
276 """Export a tagged commit as distribution-ready release artifacts.
277
278 Resolves TAG to a commit (via ``muse tag add``), fetches its snapshot,
279 and produces the requested artifacts under the output directory. Always
280 writes a ``release-manifest.json`` with SHA-256 checksums.
281
282 Examples::
283
284 muse release v1.0 --render-audio
285 muse release v1.0 --render-midi --export-stems --format flac
286 muse release v1.0 --render-audio --output-dir ~/dist/v1.0
287 """
288 root = require_repo()
289
290 async def _run() -> ReleaseResult:
291 async with open_session() as session:
292 return await _release_async(
293 tag=tag,
294 audio_format=audio_format,
295 output_dir=output_dir,
296 render_audio=render_audio,
297 render_midi=render_midi,
298 export_stems=export_stems,
299 root=root,
300 session=session,
301 )
302
303 try:
304 result = asyncio.run(_run())
305 except typer.Exit:
306 raise
307 except StorpheusReleaseUnavailableError as exc:
308 typer.echo(f"❌ Storpheus not reachable — audio render aborted.\n{exc}")
309 logger.error("muse release: Storpheus unavailable: %s", exc)
310 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
311 except ValueError as exc:
312 typer.echo(f"❌ {exc}")
313 logger.error("muse release: %s", exc)
314 raise typer.Exit(code=ExitCode.USER_ERROR)
315 except Exception as exc:
316 typer.echo(f"❌ muse release failed: {exc}")
317 logger.error("muse release error: %s", exc, exc_info=True)
318 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
319
320 if as_json:
321 payload = {
322 "tag": result.tag,
323 "commit_id": result.commit_id,
324 "commit_short": result.commit_id[:8],
325 "output_dir": str(result.output_dir),
326 "manifest_path": str(result.manifest_path),
327 "audio_format": result.audio_format.value,
328 "stubbed": result.stubbed,
329 "artifacts": [
330 {
331 "path": str(a.path),
332 "sha256": a.sha256,
333 "size_bytes": a.size_bytes,
334 "role": a.role,
335 }
336 for a in result.artifacts
337 ],
338 }
339 typer.echo(json_mod.dumps(payload, indent=2))
340 else:
341 non_manifest = [a for a in result.artifacts if a.role != "manifest"]
342 if non_manifest:
343 typer.echo(
344 f"✅ Release artifacts for tag {result.tag!r} "
345 f"(commit {result.commit_id[:8]}):"
346 )
347 for a in non_manifest:
348 typer.echo(f" [{a.role}] {a.path}")
349 else:
350 typer.echo(
351 f"⚠️ No render flags specified — only manifest written for tag {result.tag!r}."
352 "\nUse --render-audio, --render-midi, or --export-stems."
353 )
354
355 typer.echo(f" [manifest] {result.manifest_path}")
356
357 if result.stubbed:
358 typer.echo(
359 "⚠️ Audio files are MIDI stubs (Storpheus /render endpoint not yet deployed)."
360 )
361
362 logger.info(
363 "muse release: tag=%r commit=%s output_dir=%s artifacts=%d stubbed=%s",
364 result.tag,
365 result.commit_id[:8],
366 result.output_dir,
367 len(result.artifacts),
368 result.stubbed,
369 )