gabriel / musehub public
musehub.py python
3,384 lines 143.9 KB
Raw
sha256:3707eba7ad42cadedf18c8b9c534d839b88cfd1c30924c3c5a3edc74e1d809de feat: add url field to mist, issue, and proposal list/read … Sonnet 4.6 minor ⚠ breaking 7 days ago
1 """Pydantic v2 request/response models for the MuseHub API.
2
3 All wire-format fields use camelCase via CamelModel. Python code uses
4 snake_case throughout; only serialisation to JSON uses camelCase.
5 """
6
7 import re
8 from datetime import datetime
9 from enum import Enum
10 from typing import Annotated, Literal, NotRequired, TypedDict
11
12 from pydantic import ConfigDict, Field, ValidationInfo, field_validator, model_validator
13
14 from musehub.models.base import CamelModel
15 from musehub.types.json_types import JSONObject, StrDict
16 from musehub.types.pydantic_types import PydanticJson
17
18 type _JsonMeta = dict[str, PydanticJson]
19
20 # ── Genesis-addressed entity ID validation ────────────────────────────────────
21 # All first-class entities (repo, issue, proposal, comment, session, release)
22 # carry genesis-addressed IDs of the canonical form <algo>:<hex-digest>.
23 #
24 # The algorithm prefix is lower-case alphanumeric (e.g. "sha256", "blake3").
25 # The digest is lowercase hex of at least 32 chars (128-bit minimum).
26 # This pattern accepts any future hash algorithm without a code change —
27 # the prefix encodes the algorithm, exactly as the Muse Cryptographic Codec
28 # specifies. Do NOT tighten this to "sha256" only.
29 _GENESIS_ID_RE: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9]*:[0-9a-f]{32,}$")
30
31 def _check_genesis_id(field_name: str, v: str) -> str:
32 if not _GENESIS_ID_RE.match(v):
33 raise ValueError(
34 f"invalid {field_name} {v!r}: must be 'sha256:<64 lowercase hex chars>'"
35 )
36 return v
37
38 # ── Sync protocol models ──────────────────────────────────────────────────────
39
40 class CommitInput(CamelModel):
41 """A single commit record transferred in a push payload."""
42
43 commit_id: str = Field(
44 ...,
45 description="Content-addressed commit ID (e.g. SHA-256 hex)",
46 examples=["a3f8c1d2e4b5"],
47 )
48 parent_ids: list[str] = Field(
49 default_factory=list,
50 description="Parent commit IDs; empty for the initial commit",
51 examples=[["b2a7d9e1c3f4"]],
52 )
53 message: str = Field(
54 ...,
55 max_length=10_000,
56 description="Musical commit message describing the compositional change",
57 examples=["Add dominant 7th chord progression in the bridge — Fm7→Bb7→EbMaj7"],
58 )
59 snapshot_id: str | None = Field(
60 default=None,
61 description="Optional snapshot ID linking this commit to a stored MIDI artifact",
62 )
63 timestamp: datetime = Field(..., description="Commit creation time (ISO-8601 UTC)")
64 # Optional -- falls back to the MSign handle when absent
65 author: str | None = Field(
66 default=None,
67 description="Commit author identifier; defaults to the MSign handle when absent",
68 examples=["[email protected]"],
69 )
70
71 class ObjectInput(CamelModel):
72 """A binary object transferred in a push payload.
73
74 Content is base64-encoded. For MVP, objects up to ~1 MB are fine; larger
75 files will require pre-signed URL upload in a future release.
76 """
77
78 object_id: str = Field(..., description="Content-addressed ID, e.g. 'sha256:abc...'")
79 path: str = Field(..., description="Relative path hint, e.g. 'tracks/jazz_4b.mid'")
80 content_b64: str = Field(..., description="Base64-encoded binary content")
81
82 class SnapshotInput(CamelModel):
83 """A snapshot manifest transferred in a push payload.
84
85 A snapshot maps file paths to content-addressed object IDs. Snapshots
86 are idempotent: pushing a snapshot whose ``snapshot_id`` already exists
87 is a no-op.
88 """
89
90 snapshot_id: str = Field(..., description="Content-addressed snapshot ID (SHA-256 of sorted path:oid pairs)")
91 manifest: StrDict = Field(
92 default_factory=dict,
93 description="Mapping of relative file path → object_id, e.g. {'tracks/bass.mid': 'sha256:abc...'}",
94 )
95 created_at: str = Field(default="", description="ISO-8601 UTC creation timestamp")
96
97 class PushResponse(CamelModel):
98 """Response for POST /musehub/repos/{repo_id}/push."""
99
100 ok: bool = Field(..., description="True when the push succeeded", examples=[True])
101 remote_head: str = Field(
102 ...,
103 description="The new branch head commit ID on the remote after push",
104 examples=["a3f8c1d2e4b5"],
105 )
106
107 class ObjectResponse(CamelModel):
108 """A binary object returned in a pull response."""
109
110 object_id: str
111 path: str
112 content_b64: str
113
114 class PullResponse(CamelModel):
115 """Response for POST /musehub/repos/{repo_id}/pull.
116
117 Pagination is cursor-based: when ``has_more`` is ``True`` the caller
118 should re-issue the pull with ``cursor`` set to ``next_cursor`` to fetch
119 the next page of objects. Commits are always returned in full (typically
120 small); only the object list is paginated.
121 """
122
123 commits: list[CommitResponse]
124 objects: list[ObjectResponse]
125 remote_head: str | None
126 has_more: bool = False
127 next_cursor: str | None = None
128
129 # ── Request models ────────────────────────────────────────────────────────────
130
131 class CreateRepoRequest(CamelModel):
132 """Body for POST /musehub/repos — creation wizard.
133
134 ``owner`` is the URL-visible username that appears in /{owner}/{slug} paths.
135 ``slug`` is auto-generated from ``name`` — lowercase, hyphens, 1–64 chars.
136
137 Wizard fields:
138 - ``initialize``: when True, an empty "Initial commit" + default branch are
139 created immediately so the repo is browsable right away.
140 - ``default_branch``: branch name used when ``initialize=True``.
141 - ``template_repo_id``: if set, topics/description are copied from that
142 public repo before creation.
143 - ``license``: SPDX identifier or common shorthand (e.g. "CC BY 4.0").
144 - ``topics``: genre/mood labels analogous to GitHub topics; merged with
145 ``tags`` into a single tag list on the server.
146 """
147
148 name: str = Field(..., min_length=1, max_length=255, description="Repo name")
149 owner: str = Field(
150 ...,
151 min_length=1,
152 max_length=64,
153 pattern=r"^[a-z0-9]([a-z0-9\-]{0,62}[a-z0-9])?$",
154 description="URL-safe owner username (lowercase alphanumeric + hyphens, no leading/trailing hyphens)",
155 )
156 visibility: str = Field("public")
157 description: str = Field("", description="Short description shown on the explore page")
158 tags: list[str] = Field(
159 default_factory=list,
160 description="Free-form tags -- genre, key, instrumentation (e.g. 'jazz', 'F# minor', 'bass')",
161 )
162 # ── Wizard extensions ────────────────────────────────────────
163 license: str | None = Field(None, max_length=100, description="License identifier (e.g. 'CC BY 4.0', 'MIT')")
164 topics: list[str] = Field(
165 default_factory=list,
166 description="Genre/mood topic labels merged with tags (e.g. 'classical', 'piano')",
167 )
168 initialize: bool = Field(
169 True,
170 description="When true, create an initial empty commit + default branch so the repo is immediately browsable",
171 )
172 default_branch: str = Field(
173 "main",
174 min_length=1,
175 max_length=255,
176 description="Name of the default branch created when initialize=true",
177 )
178 template_repo_id: str | None = Field(
179 None,
180 description="Genesis-addressed ID of a public repo to copy topics/description/labels from; must be public",
181 )
182 domain: str = Field(
183 "",
184 description="Domain slug for this repo (e.g. 'code', 'midi', 'audio'). Defaults to 'code' when absent.",
185 )
186 domain_scoped_id: str | None = Field(
187 None,
188 description="Scoped domain ID (e.g. '@gabriel/midi') — always set when created via /domains/@author/slug/new",
189 )
190
191 @field_validator("template_repo_id")
192 @classmethod
193 def _check_template_repo_id(cls, v: str | None) -> str | None:
194 if v is not None:
195 _check_genesis_id("template_repo_id", v)
196 return v
197
198 # ── Response models ───────────────────────────────────────────────────────────
199
200 class RepoResponse(CamelModel):
201 """Wire representation of a MuseHub repo.
202
203 ``owner`` and ``slug`` together form the canonical /{owner}/{slug} URL scheme.
204 ``repo_id`` is the internal sha256 genesis hash primary key — never exposed in external URLs.
205 """
206
207 repo_id: str = Field(..., description="Internal sha256 genesis hash primary key for this repo", examples=["sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"])
208 name: str = Field(..., description="Human-readable repo name", examples=["jazz-standards-2024"])
209 owner: str = Field(..., description="URL-visible owner username", examples=["miles_davis"])
210 slug: str = Field(..., description="URL-safe slug auto-generated from name", examples=["jazz-standards-2024"])
211 visibility: str = Field(..., description="'public' or 'private'", examples=["public"])
212 owner_user_id: str = Field(..., description="sha256 genesis hash of the owning user account")
213 clone_url: str = Field(..., description="URL used by the CLI for push/pull", examples=["https://musehub.ai/api/repos/e3b0c44298fc"])
214 description: str = Field("", description="Short description shown on the explore page", examples=["Classic jazz standards arranged for quartet"])
215 tags: list[str] = Field(default_factory=list, description="Free-form tags for discovery and search", examples=[["python", "api", "open-source"]])
216 domain_id: str | None = Field(None, description="ID of the registered Muse domain plugin for this repo")
217 domain: str = Field("generic", description="Human-readable domain slug (e.g. 'code', 'midi', 'audio'). 'generic' when no domain plugin is registered.")
218 default_branch: str = Field("main", description="Default branch name for this repo", examples=["main"])
219 created_at: datetime = Field(..., description="Repo creation timestamp (ISO-8601 UTC)")
220 updated_at: datetime = Field(..., description="Last metadata update timestamp (ISO-8601 UTC)")
221 pushed_at: datetime | None = Field(None, description="Last push timestamp (ISO-8601 UTC); null if never pushed")
222
223 @field_validator("repo_id")
224 @classmethod
225 def _check_repo_id(cls, v: str) -> str:
226 return _check_genesis_id("repo_id", v)
227
228 class TransferOwnershipRequest(CamelModel):
229 """Request body for transferring repo ownership to another user."""
230
231 new_owner_user_id: str = Field(
232 ..., description="User ID of the new repo owner", examples=["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]
233 )
234
235 class RepoListResponse(CamelModel):
236 """Paginated list of repos for the authenticated user.
237
238 Covers repos they own plus repos they collaborate on. The ``next_cursor``
239 opaque string is passed back as ``?cursor=`` to retrieve the next page;
240 a null value means there are no more results.
241 """
242
243 repos: list[RepoResponse] = Field(..., description="Repos on this page (up to 20)")
244 next_cursor: str | None = Field(None, description="Pagination cursor — pass as ?cursor= to get the next page")
245 total: int = Field(..., description="Total number of repos across all pages")
246
247 class BranchResponse(CamelModel):
248 """Wire representation of a branch pointer."""
249
250 branch_id: str = Field(..., description="Internal sha256 genesis hash for this branch")
251 name: str = Field(..., description="Branch name", examples=["main", "feat/jazz-bridge"])
252 head_commit_id: str | None = Field(None, description="HEAD commit ID; null for an empty branch", examples=["a3f8c1d2e4b5"])
253
254 class CommitResponse(CamelModel):
255 """Wire representation of a pushed commit."""
256
257 commit_id: str = Field(..., description="Content-addressed commit ID", examples=["a3f8c1d2e4b5"])
258 branch: str = Field(..., description="Branch this commit was pushed to", examples=["main"])
259 parent_ids: list[str] = Field(..., description="Parent commit IDs", examples=[["b2a7d9e1c3f4"]])
260 message: str = Field(
261 ...,
262 description="Musical commit message",
263 examples=["Increase tempo from 120→132 BPM in the chorus for more energy"],
264 )
265 author: str = Field(..., description="Commit author identifier", examples=["[email protected]"])
266 timestamp: datetime = Field(..., description="Commit creation time (ISO-8601 UTC)")
267 snapshot_id: str | None = Field(default=None, description="Optional snapshot artifact ID")
268
269 class BranchListResponse(CamelModel):
270 """Paginated list of branches."""
271
272 branches: list[BranchResponse]
273
274
275 class BranchResetRequest(CamelModel):
276 commit_id: str
277
278
279 class BranchResetResponse(CamelModel):
280 branch: str
281 commit_id: str
282 previous_commit_id: str
283
284 class BranchDivergenceScores(CamelModel):
285 """Placeholder musical divergence scores between a branch and the default branch.
286
287 These five dimensions mirror the ``muse divergence`` command output. Values
288 are floats in [0.0, 1.0] where 0 = identical and 1 = maximally different.
289 All fields are ``None`` when divergence cannot yet be computed server-side
290 (e.g. no audio snapshots attached to commits).
291 """
292
293 melodic: float | None = Field(None, description="Melodic divergence (0–1)")
294 harmonic: float | None = Field(None, description="Harmonic divergence (0–1)")
295 rhythmic: float | None = Field(None, description="Rhythmic divergence (0–1)")
296 structural: float | None = Field(None, description="Structural divergence (0–1)")
297 dynamic: float | None = Field(None, description="Dynamic divergence (0–1)")
298
299 class BranchDetailResponse(CamelModel):
300 """Branch pointer enriched with ahead/behind counts and musical divergence.
301
302 Used by the branch list page (``GET /{owner}/{repo}/branches``) to give
303 musicians a quick overview of how each branch relates to the default branch.
304 """
305
306 branch_id: str = Field(..., description="Internal sha256 genesis hash for this branch")
307 name: str = Field(..., description="Branch name", examples=["main", "feat/jazz-bridge"])
308 head_commit_id: str | None = Field(None, description="HEAD commit ID; null for an empty branch")
309 is_default: bool = Field(False, description="True when this is the repo's default branch")
310 ahead_count: int = Field(0, ge=0, description="Commits on this branch not yet on the default branch")
311 behind_count: int = Field(0, ge=0, description="Commits on the default branch not yet on this branch")
312 divergence: BranchDivergenceScores = Field(
313 default_factory=lambda: BranchDivergenceScores(
314 melodic=None, harmonic=None, rhythmic=None, structural=None, dynamic=None
315 ),
316 description="Musical divergence scores vs the default branch (placeholder until computable)",
317 )
318
319 class BranchDetailListResponse(CamelModel):
320 """List of branches with detail — used by the branch list page and its JSON variant."""
321
322 branches: list[BranchDetailResponse]
323 default_branch: str = Field("main", description="Name of the repo's default branch")
324
325 class TagResponse(CamelModel):
326 """A single tag entry for the tag browser page.
327
328 Tags are sourced from ``musehub_releases``. The ``namespace`` field is
329 derived from the tag name: ``emotion:happy`` → namespace ``emotion``,
330 ``v1.0`` → namespace ``version``.
331 """
332
333 tag: str = Field(..., description="Full tag string (e.g. 'emotion:happy', 'v1.0')")
334 namespace: str = Field(..., description="Namespace prefix (e.g. 'emotion', 'genre', 'version')")
335 commit_id: str | None = Field(None, description="Commit this tag is pinned to")
336 message: str = Field("", description="Release title / description")
337 created_at: datetime = Field(..., description="Tag creation timestamp (ISO-8601 UTC)")
338
339 class TagListResponse(CamelModel):
340 """All tags for a repo, grouped by namespace.
341
342 ``namespaces`` is an ordered list of distinct namespace strings present in
343 the repo. ``tags`` is the flat list; clients should filter/group client-side
344 using the ``namespace`` field.
345 """
346
347 tags: list[TagResponse]
348 namespaces: list[str] = Field(default_factory=list, description="Distinct namespaces present in this repo")
349
350 class CommitListResponse(CamelModel):
351 """Cursor-paginated list of commits (newest first).
352
353 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page.
354 A null ``nextCursor`` means this is the last page.
355 """
356
357 commits: list[CommitResponse]
358 total: int
359 next_cursor: str | None = Field(
360 None,
361 description=(
362 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
363 "Null when this is the last page."
364 ),
365 )
366
367 # ---------------------------------------------------------------------------
368 # Snapshots
369 # ---------------------------------------------------------------------------
370
371 class SnapshotEntryResponse(CamelModel):
372 """One file-tree entry within a snapshot.
373
374 Maps a workspace-relative path to a content-addressed object ID.
375 ``size_bytes`` is stored at write time and avoids a join to the objects
376 table when rendering the file browser or computing totals.
377
378 Agent note: ``object_id`` is the key you pass to
379 ``GET /api/repos/{repo_id}/objects/{object_id}/content`` to fetch raw bytes.
380 """
381
382 path: str = Field(
383 ...,
384 description="Workspace-relative file path (e.g. 'muse/core/store.py')",
385 examples=["muse/core/store.py"],
386 )
387 object_id: str = Field(
388 ...,
389 description="Content-addressed object ID — pass to the objects endpoint to download",
390 examples=["a3f8c1d2e4b5690f2c1a8d3e7b9f0142"],
391 )
392 size_bytes: int = Field(
393 0,
394 ge=0,
395 description="Stored file size in bytes; 0 when unknown",
396 examples=[4096],
397 )
398
399 class SnapshotSummaryResponse(CamelModel):
400 """Lightweight snapshot summary — no file-tree entries.
401
402 Returned by ``GET /api/repos/{repo_id}/snapshots`` (list view).
403 Use the full-detail endpoint to fetch entries.
404
405 Agent note: ``entry_count`` tells you how many files are in this snapshot
406 without loading the manifest. Use ``file_count`` for display; fetch
407 ``/snapshots/{snapshot_id}`` when you need the manifest itself.
408 """
409
410 snapshot_id: str = Field(
411 ...,
412 description="Content-addressed snapshot ID (SHA-256 of sorted path:oid pairs)",
413 examples=["836cbed7d608984d0f3a2b1c4e5f6a7b"],
414 )
415 repo_id: str = Field(..., description="Repo this snapshot belongs to")
416 entry_count: int = Field(
417 0,
418 ge=0,
419 description="Number of file-tree entries (files) tracked at this snapshot",
420 examples=[142],
421 )
422 total_size_bytes: int = Field(
423 0,
424 ge=0,
425 description="Sum of all entry size_bytes; 0 when sizes were not recorded",
426 examples=[1048576],
427 )
428 directories: list[str] = Field(
429 default_factory=list,
430 description="Sorted list of workspace-relative directory paths included in the snapshot hash",
431 examples=[["muse", "muse/core", "muse/cli", "tests"]],
432 )
433 created_at: datetime = Field(..., description="When this snapshot was first pushed (ISO-8601 UTC)")
434
435 class SnapshotResponse(CamelModel):
436 """Full snapshot record including all file-tree entries.
437
438 Returned by ``GET /api/repos/{repo_id}/snapshots/{snapshot_id}``.
439
440 Agent note: iterate ``entries`` to get the complete ``{path: object_id}``
441 manifest. For repos with thousands of files, paginate via
442 ``GET /api/repos/{repo_id}/snapshots/{snapshot_id}/entries`` instead.
443 """
444
445 snapshot_id: str = Field(
446 ...,
447 description="Content-addressed snapshot ID",
448 examples=["836cbed7d608984d0f3a2b1c4e5f6a7b"],
449 )
450 repo_id: str = Field(..., description="Repo this snapshot belongs to")
451 directories: list[str] = Field(
452 default_factory=list,
453 description="Sorted workspace-relative directory paths included in the snapshot hash",
454 examples=[["muse", "muse/core"]],
455 )
456 entries: list[SnapshotEntryResponse] = Field(
457 default_factory=list,
458 description="File-tree entries sorted by path (alphabetical)",
459 )
460 entry_count: int = Field(
461 0,
462 ge=0,
463 description="Total number of entries (equal to len(entries) unless paginated)",
464 examples=[142],
465 )
466 total_size_bytes: int = Field(
467 0,
468 ge=0,
469 description="Sum of all entry size_bytes",
470 examples=[1048576],
471 )
472 created_at: datetime = Field(..., description="When this snapshot was first pushed (ISO-8601 UTC)")
473
474 class SnapshotListResponse(CamelModel):
475 """Cursor-paginated list of snapshot summaries (newest first).
476
477 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page.
478 A null ``nextCursor`` means this is the last page.
479 The ``Link: <url>; rel="next"`` response header carries the same signal
480 for HTTP-native clients.
481 """
482
483 snapshots: list[SnapshotSummaryResponse]
484 total: int = Field(..., ge=0, description="Total snapshots in this repo across all pages")
485 next_cursor: str | None = Field(
486 None,
487 description=(
488 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
489 "Null when this is the last page."
490 ),
491 )
492
493 class SnapshotEntryListResponse(CamelModel):
494 """Cursor-paginated file-tree entries for a single snapshot.
495
496 Used when the full entry list is too large to return inline.
497 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page.
498 A null ``nextCursor`` means this is the last page.
499 """
500
501 snapshot_id: str
502 entries: list[SnapshotEntryResponse]
503 total: int = Field(..., ge=0, description="Total entries in this snapshot")
504 next_cursor: str | None = Field(
505 None,
506 description=(
507 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
508 "Null when this is the last page."
509 ),
510 )
511
512 class SnapshotDiffEntry(CamelModel):
513 """A single file-level change between two snapshots.
514
515 ``status`` values:
516 - ``added``: path present in new snapshot, absent in base
517 - ``removed``: path present in base snapshot, absent in new
518 - ``modified``: path present in both, object_id changed
519 - ``unchanged``: path present in both with identical object_id (only emitted
520 when ``include_unchanged=true`` is requested)
521
522 Agent note: filter on ``status`` to build targeted summaries — e.g. only
523 ``modified`` entries to see what content changed between two commits.
524 """
525
526 path: str = Field(..., description="Workspace-relative file path")
527 status: str = Field(
528 ...,
529 description="Change kind: 'added' | 'removed' | 'modified' | 'unchanged'",
530 examples=["modified"],
531 )
532 base_object_id: str | None = Field(
533 None,
534 description="Object ID in the base snapshot (null for added files)",
535 examples=["b2a7d9e1c3f4"],
536 )
537 new_object_id: str | None = Field(
538 None,
539 description="Object ID in the new snapshot (null for removed files)",
540 examples=["c3b8e0f2d5a6"],
541 )
542 base_size_bytes: int = Field(0, ge=0, description="File size in base snapshot")
543 new_size_bytes: int = Field(0, ge=0, description="File size in new snapshot")
544
545 class SnapshotDiffResponse(CamelModel):
546 """File-level diff between two snapshots.
547
548 Returned by ``GET /api/repos/{repo_id}/snapshots/{snapshot_id}/diff?base={base_id}``.
549
550 Agent note: ``added_count + removed_count + modified_count`` gives the total
551 changed file count. Iterate ``changes`` for path-level detail. Use
552 ``bytes_added`` and ``bytes_removed`` for storage-delta analysis.
553 """
554
555 snapshot_id: str = Field(..., description="The 'new' snapshot being compared")
556 base_snapshot_id: str = Field(..., description="The 'base' snapshot being compared against")
557 added_count: int = Field(0, ge=0, description="Files added in new snapshot")
558 removed_count: int = Field(0, ge=0, description="Files removed from base snapshot")
559 modified_count: int = Field(0, ge=0, description="Files present in both but with changed content")
560 unchanged_count: int = Field(0, ge=0, description="Files identical in both snapshots")
561 bytes_added: int = Field(0, ge=0, description="Total bytes added (new file sizes)")
562 bytes_removed: int = Field(0, ge=0, description="Total bytes removed (base file sizes)")
563 changes: list[SnapshotDiffEntry] = Field(
564 default_factory=list,
565 description="Per-file change list, sorted by path",
566 )
567
568 class SnapshotBatchRequest(CamelModel):
569 """Request body for the batch snapshot lookup endpoint.
570
571 Agent note: supply up to 100 snapshot IDs to resolve manifests in a single
572 round-trip instead of N sequential GET requests.
573 """
574
575 snapshot_ids: list[str] = Field(
576 ...,
577 min_length=1,
578 max_length=100,
579 description="Up to 100 snapshot IDs to look up",
580 examples=[["836cbed7d608984d", "a1b2c3d4e5f60001"]],
581 )
582 include_entries: bool = Field(
583 False,
584 description="When true, each result includes its file-tree entries (heavier)",
585 )
586
587 class RepoStatsResponse(CamelModel):
588 """Aggregated counts for the repo home page stats bar.
589
590 Returned by ``GET /api/repos/{repo_id}/stats``.
591 All counts are non-negative integers; 0 when the repo has no data yet.
592 """
593
594 commit_count: int = Field(0, ge=0, description="Total number of commits across all branches")
595 branch_count: int = Field(0, ge=0, description="Number of branches (including default)")
596 release_count: int = Field(0, ge=0, description="Number of published releases / tags")
597
598 # ── Issue models ───────────────────────────────────────────────────────────────
599
600 # Reusable constrained element types for issue list fields.
601 _Label = Annotated[str, Field(min_length=1, max_length=100)]
602 _SymbolAnchor = Annotated[str, Field(min_length=1, max_length=500)]
603 _CommitAnchor = Annotated[str, Field(min_length=1, max_length=71)]
604
605 class IssueCreate(CamelModel):
606 """Body for POST /musehub/repos/{repo_id}/issues."""
607
608 title: str = Field(
609 ...,
610 min_length=1,
611 max_length=500,
612 description="Issue title",
613 examples=["Verse chord progression feels unresolved — needs perfect cadence at bar 16"],
614 )
615 body: str = Field(
616 "",
617 max_length=50_000,
618 description="Issue description (Markdown)",
619 examples=["The Dm→Am→E7→Am progression in the verse doesn't resolve — suggest Dm→G7→CMaj7."],
620 )
621 labels: list[_Label] = Field(
622 default_factory=list,
623 max_length=20,
624 description="Free-form label strings (max 20, each max 100 chars)",
625 examples=[["harmony", "needs-review"]],
626 )
627 symbol_anchors: list[_SymbolAnchor] = Field(
628 default_factory=list,
629 max_length=50,
630 description="Symbol addresses to anchor this issue to (max 50, each max 500 chars)",
631 )
632 commit_anchors: list[_CommitAnchor] = Field(
633 default_factory=list,
634 max_length=50,
635 description="Commit IDs to anchor this issue to (max 50, each max 64 chars)",
636 )
637 agent_id: str = Field(
638 "",
639 max_length=255,
640 description="Agent identifier when filed by an AI agent (e.g. 'agentception-worker-42')",
641 )
642 model_id: str = Field(
643 "",
644 max_length=255,
645 description="Model identifier when filed by an AI agent (e.g. 'claude-sonnet-4-6')",
646 )
647
648 class IssueUpdate(CamelModel):
649 """Body for PATCH /musehub/repos/{repo_id}/issues/{number} — partial update.
650
651 All fields are optional; only non-None fields are applied.
652 """
653
654 title: str | None = Field(None, min_length=1, max_length=500, description="Updated issue title")
655 body: str | None = Field(None, max_length=50_000, description="Updated issue body (Markdown)")
656 labels: list[_Label] | None = Field(None, max_length=20, description="Replacement label list (max 20, each max 100 chars)")
657 symbol_anchors: list[_SymbolAnchor] | None = Field(None, max_length=50, description="Replacement symbol anchor list (max 50, each max 500 chars)")
658 commit_anchors: list[_CommitAnchor] | None = Field(None, max_length=50, description="Replacement commit anchor list (max 50, each max 64 chars)")
659 agent_id: str | None = Field(None, max_length=255, description="Agent identifier (set when agent-filed)")
660 model_id: str | None = Field(None, max_length=255, description="Model identifier (set when agent-filed)")
661
662 class IssueResponse(CamelModel):
663 """Wire representation of a MuseHub issue."""
664
665 issue_id: str = Field(..., description="Internal sha256 genesis hash for this issue")
666 number: int = Field(..., description="Per-repo sequential issue number", examples=[42])
667 title: str = Field(..., description="Issue title", examples=["Verse chord progression feels unresolved"])
668 body: str = Field(..., description="Issue description (Markdown)")
669 state: str = Field(..., description="'open' or 'closed'", examples=["open"])
670 labels: list[str] = Field(..., description="Labels attached to this issue", examples=[["harmony"]])
671 # Structured symbol anchors: ["file.py::Symbol", …]
672 symbol_anchors: list[str] = Field(default_factory=list, description="Symbol addresses anchored to this issue")
673 # Commit ID anchors: ["a3f2c9ef…", …]
674 commit_anchors: list[str] = Field(default_factory=list, description="Commit IDs anchored to this issue")
675 author: str = ""
676 # Collaborator assigned to resolve this issue; null when unassigned
677 assignee: str | None = Field(None, description="Display name of the assigned collaborator")
678 # Agent provenance — populated when filed by an AI agent
679 agent_id: str = Field("", description="Agent identifier if filed by an AI agent")
680 model_id: str = Field("", description="Model identifier if filed by an AI agent")
681 created_at: datetime = Field(..., description="Issue creation timestamp (ISO-8601 UTC)")
682 updated_at: datetime | None = Field(None, description="Last update timestamp (ISO-8601 UTC)")
683 comment_count: int = Field(0, description="Number of non-deleted comments on this issue")
684
685 @field_validator("issue_id")
686 @classmethod
687 def _check_issue_id(cls, v: str) -> str:
688 return _check_genesis_id("issue_id", v)
689
690 class IssueListResponse(CamelModel):
691 """Cursor-paginated list of issues for a repo.
692
693 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page.
694 A null ``nextCursor`` means this is the last page. ``total`` reflects
695 the count of all matching issues regardless of the current page so UIs
696 can show "showing N of M" without paginating through everything.
697 The ``Link: <url>; rel="next"`` response header carries the same signal
698 for HTTP-native clients.
699 """
700
701 issues: list[IssueResponse]
702 total: int = Field(0, ge=0, description="Total matching issues across all pages")
703 next_cursor: str | None = Field(
704 None,
705 description=(
706 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
707 "Null when this is the last page."
708 ),
709 )
710
711 # ── Issue comment models ───────────────────────────────────────────────────────
712
713 class IssueCommentCreate(CamelModel):
714 """Body for POST /musehub/repos/{repo_id}/issues/{number}/comments."""
715
716 body: str = Field(
717 ...,
718 min_length=1,
719 max_length=50_000,
720 description="Comment body (Markdown).",
721 )
722 parent_id: str | None = Field(
723 None,
724 description="Parent comment genesis-addressed hash for threaded replies; omit for top-level comments",
725 )
726
727 @field_validator("parent_id")
728 @classmethod
729 def _check_parent_id(cls, v: str | None) -> str | None:
730 if v is not None:
731 _check_genesis_id("parent_id", v)
732 return v
733
734 class IssueCommentResponse(CamelModel):
735 """Wire representation of a single issue comment."""
736
737 comment_id: str = Field(..., description="Internal sha256 genesis hash for this comment")
738 issue_id: str = Field(..., description="sha256 genesis hash of the issue this comment belongs to")
739 author: str = Field(..., description="Display name of the comment author")
740 body: str = Field(..., description="Comment body (Markdown)")
741 parent_id: str | None = Field(None, description="Parent comment sha256 genesis hash; null for top-level comments")
742 is_deleted: bool = Field(False, description="True when the comment has been soft-deleted")
743 created_at: datetime = Field(..., description="Comment creation timestamp (ISO-8601 UTC)")
744 updated_at: datetime = Field(..., description="Last edit timestamp (ISO-8601 UTC)")
745
746 @field_validator("comment_id", "issue_id")
747 @classmethod
748 def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str:
749 return _check_genesis_id(getattr(info, "field_name", "id"), v)
750
751 class IssueCommentListResponse(CamelModel):
752 """Cursor-paginated discussion on a single issue.
753
754 Comments are returned in chronological order (oldest first). Top-level
755 comments have ``parent_id=None``; replies reference their parent via
756 ``parent_id``. Clients build the thread tree client-side.
757 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page.
758 A null ``nextCursor`` means this is the last page.
759 """
760
761 comments: list[IssueCommentResponse]
762 total: int
763 next_cursor: str | None = Field(
764 None,
765 description=(
766 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
767 "Null when this is the last page."
768 ),
769 )
770
771 # ── Issue assignee models ─────────────────────────────────────────────────────
772
773 class IssueAssignRequest(CamelModel):
774 """Body for POST /musehub/repos/{repo_id}/issues/{number}/assign."""
775
776 assignee: str | None = Field(
777 None,
778 description="Display name or user ID to assign; null to unassign",
779 examples=["miles_davis"],
780 )
781
782 class IssueLabelAssignRequest(CamelModel):
783 """Body for POST /musehub/repos/{repo_id}/issues/{number}/labels.
784
785 Replaces the entire label list on the issue. To append labels, fetch the
786 current list first, merge client-side, and post the merged result.
787 """
788
789 labels: list[_Label] = Field(
790 ...,
791 max_length=20,
792 description="Replacement label list for the issue (max 20, each max 100 chars)",
793 examples=[["harmony", "needs-review"]],
794 )
795
796 # ── Proposal enums & types ────────────────────────────────────────────────
797
798 class ProposalType(str, Enum):
799 """Seven first-class proposal types. Type determines relevant domains,
800 required reviewer archetypes, and valid merge strategies."""
801 STATE_MERGE = "state_merge"
802 STEM_INTEGRATION = "stem_integration"
803 MIDI_EVOLUTION = "midi_evolution"
804 PAYMENT_SETTLEMENT = "payment_settlement"
805 AGENT_DELEGATION = "agent_delegation"
806 IDENTITY_TRANSITION = "identity_transition"
807 CANONICAL_RELEASE = "canonical_release"
808
809
810 class ProposalState(str, Enum):
811 """Seven-state lifecycle. MERGED and ABANDONED are terminal — no transition out."""
812 DRAFTING = "drafting"
813 OPEN = "open"
814 IN_REVIEW = "in_review"
815 APPROVED = "approved"
816 SETTLING = "settling"
817 MERGED = "merged"
818 ABANDONED = "abandoned"
819
820
821 class MergeStrategy(str, Enum):
822 """Merge execution strategy. DOMAIN_SELECTIVE requires selective_domains to be set."""
823 STATE_OVERLAY = "state_overlay"
824 STATE_WEAVE = "state_weave"
825 STATE_REBASE = "state_rebase"
826 DOMAIN_SELECTIVE = "domain_selective"
827 PHASED = "phased"
828 CHERRY_PICK = "cherry_pick"
829
830
831 class MergeConditions(CamelModel):
832 """Declarative preconditions that must all be satisfied before a proposal may merge.
833
834 Evaluated by ``check_merge_conditions()`` in proposal_dag.py. All fields are
835 optional; defaults represent the permissive baseline. Set at the repo level via
836 ``.muse/proposal_defaults.toml`` or overridden per proposal.
837 """
838 require_approvals: int = Field(2, ge=0, description="Minimum distinct approved reviews")
839 require_domains_approved: list[str] = Field(default_factory=list, description="Each listed domain must have ≥1 approval")
840 max_risk_score: float = Field(1.0, ge=0.0, le=1.0, description="Proposal blocked if aggregate_risk_score exceeds this")
841 require_signed_commits: bool = Field(False, description="All commits on from_branch must carry an Ed25519 proposer_sig_b64")
842 require_no_breakage: bool = Field(False, description="breakage_count must equal 0")
843 require_test_coverage: bool = Field(False, description="test_gap_count must equal 0")
844 require_payment_settled: bool = Field(False, description="For PAYMENT_SETTLEMENT type: on-chain confirmation must be received")
845 require_dependency_merged: bool = Field(True, description="All hard depends_on proposals must be in MERGED state")
846 max_agent_commit_ratio: float = Field(1.0, ge=0.0, le=1.0, description="Maximum fraction of from_branch commits made by agents")
847
848
849 class ProposalCommentTarget(CamelModel):
850 """Domain-agnostic coordinate system for dimensional inline comments.
851
852 Exactly one of the domain-specific groups should be populated.
853 ``target_type='general'`` targets the proposal as a whole (no domain coordinate).
854 """
855 target_type: str = Field("general", description="general | code | midi | stem | payment | identity")
856 # Code domain
857 symbol_address: str | None = Field(None, description="e.g. 'auth.py::AuthService.login'")
858 line_start: int | None = None
859 line_end: int | None = None
860 # MIDI domain
861 track_name: str | None = None
862 beat_start: float | None = None
863 beat_end: float | None = None
864 note_pitch: int | None = Field(None, ge=0, le=127, description="0–127 MIDI pitch; None = whole region")
865 # Stem domain
866 stem_id: str | None = None
867 timestamp_start: float | None = None
868 timestamp_end: float | None = None
869 # Payment domain
870 nonce_hex: str | None = Field(None, description="Specific MPay claim nonce in the payment chain")
871 # Identity domain
872 identity_handle: str | None = None
873
874
875 # Per-domain risk float in [0.0, 1.0], keyed by domain name ("code", "midi", …)
876 DimensionalRiskVector = dict[str, float]
877
878
879 # ── Proposal models ────────────────────────────────────────────────────────
880
881 class ProposalCreate(CamelModel):
882 """Body for POST /musehub/repos/{repo_id}/proposals."""
883
884 title: str = Field(
885 ...,
886 min_length=1,
887 max_length=500,
888 description="Merge proposal title",
889 examples=["Add bossa nova bridge section with 5/4 time signature"],
890 )
891 from_branch: str = Field(
892 ...,
893 min_length=1,
894 max_length=255,
895 description="Source branch name",
896 examples=["feat/bossa-nova-bridge"],
897 )
898 to_branch: str = Field(
899 ...,
900 min_length=1,
901 max_length=255,
902 description="Target branch name",
903 examples=["main"],
904 )
905 body: str = Field(
906 "",
907 max_length=10_000,
908 description="Merge proposal description (Markdown)",
909 examples=["This branch adds an 8-bar bossa nova bridge in 5/4 with guitar and upright bass."],
910 )
911 proposal_type: ProposalType = Field(
912 ProposalType.STATE_MERGE,
913 description="Semantic type of this proposal — governs which merge strategies and conditions apply",
914 )
915 is_draft: bool = Field(
916 False,
917 description="Draft proposals are open for discussion but cannot be merged",
918 )
919 merge_conditions: MergeConditions | None = Field(
920 None,
921 description="Override the repo's default merge gate conditions for this proposal",
922 )
923 merge_strategy: MergeStrategy = Field(
924 MergeStrategy.STATE_OVERLAY,
925 description="How conflicting state is resolved when this proposal is merged",
926 )
927 selective_domains: list[str] | None = Field(
928 None,
929 description="For DOMAIN_SELECTIVE strategy: list of domain names to merge (others skipped)",
930 )
931 depends_on: list[str] = Field(
932 default_factory=list,
933 description="Proposal IDs that must be in MERGED state before this proposal can be merged",
934 )
935 proposer_public_key: str | None = Field(
936 None,
937 description="Ed25519 public key used to sign this proposal — 'ed25519:<base64url>'",
938 )
939 proposer_signature: str | None = Field(
940 None,
941 description="Ed25519 signature over the canonical PROPOSE message — 'ed25519:<base64url>'",
942 )
943 proposer_timestamp: str | None = Field(
944 None,
945 description="ISO-8601 UTC timestamp the proposer signed over (must be within ±5 min of server time)",
946 )
947
948
949 class ProposalUpdate(CamelModel):
950 """Body for PATCH /musehub/repos/{repo_id}/proposals/{proposal_id}.
951
952 All fields are optional — only supplied fields are updated.
953 At least one field must be present.
954 """
955
956 title: str | None = Field(None, min_length=1, max_length=500)
957 body: str | None = Field(None, max_length=10_000)
958 proposal_type: ProposalType | None = None
959 merge_strategy: MergeStrategy | None = None
960
961 model_config = ConfigDict(extra="forbid")
962
963 @model_validator(mode="after")
964 def at_least_one_field(self) -> "ProposalUpdate":
965 if all(v is None for v in (self.title, self.body, self.proposal_type, self.merge_strategy)):
966 raise ValueError("At least one field must be supplied")
967 return self
968
969
970 class ProposalResponse(CamelModel):
971 """Wire representation of a MuseHub merge proposal."""
972
973 proposal_id: str = Field(..., description="Internal sha256 genesis hash for this merge proposal")
974 proposal_number: int = Field(0, description="Per-repo sequential proposal number (1-based)")
975 title: str = Field(..., description="Merge proposal title", examples=["Add feature"])
976 body: str = Field(..., description="Merge proposal description (Markdown)")
977 state: str = Field(..., description="'open', 'merged', or 'closed'", examples=["open"])
978 from_branch: str = Field(..., description="Source branch name", examples=["feat/my-thing"])
979 to_branch: str = Field(..., description="Target branch name", examples=["main"])
980 merge_commit_id: str | None = Field(default=None, description="Merge commit ID; only set after merge")
981 merged_at: datetime | None = Field(default=None, description="UTC timestamp when the merge proposal was merged; None while open or closed")
982 author: str = ""
983 created_at: datetime = Field(..., description="Merge proposal creation timestamp (ISO-8601 UTC)")
984 proposal_type: ProposalType = Field(ProposalType.STATE_MERGE, description="Semantic type of this proposal")
985 is_draft: bool = Field(False, description="Draft proposals cannot be merged")
986 merge_conditions: MergeConditions | None = Field(None, description="Active merge gate conditions for this proposal")
987 merge_strategy: MergeStrategy = Field(MergeStrategy.STATE_OVERLAY, description="Conflict resolution strategy")
988 selective_domains: list[str] | None = Field(None, description="Domains targeted for DOMAIN_SELECTIVE merge")
989 depends_on: list[str] = Field(default_factory=list, description="Proposal IDs that must merge before this one")
990 risk_score: float | None = Field(None, ge=0.0, le=1.0, description="Aggregate dimensional risk score [0.0, 1.0]")
991 dimensional_risk: DimensionalRiskVector = Field(default_factory=dict, description="Per-domain risk scores")
992 # ── DAG position (populated by get_proposal enrichment) ──────────────────
993 blocked_by: list[int] = Field(
994 default_factory=list,
995 description="proposal_numbers of unmerged direct dependencies blocking this proposal",
996 )
997 blocks: list[int] = Field(
998 default_factory=list,
999 description="proposal_numbers of proposals that depend on this one",
1000 )
1001 is_blocked: bool = Field(False, description="True when len(blocked_by) > 0")
1002 # ── Inline simulation summaries (populated by get_proposal) ───────────────
1003 latest_simulations: dict[str, dict] = Field(
1004 default_factory=dict,
1005 description=(
1006 "Latest cached simulation result per type "
1007 "(conflict_scan | risk_projection | dependency_order). "
1008 "Empty dict when no simulations have been run."
1009 ),
1010 )
1011 # ── Proposer Ed25519 signature ────────────────────────────────────────────
1012 proposer_signature: str | None = Field(None, description="ed25519:<base64url> signature over the canonical PROPOSE message")
1013 proposer_public_key: str | None = Field(None, description="ed25519:<base64url> public key of the proposer")
1014 # ── Snapshot anchors ──────────────────────────────────────────────────────
1015 from_snapshot_id: str | None = Field(None, description="sha256:<hex> HEAD of from_branch at proposal creation time")
1016 to_snapshot_id: str | None = Field(None, description="sha256:<hex> HEAD of to_branch at proposal creation time")
1017
1018 @field_validator("proposal_id")
1019 @classmethod
1020 def _check_proposal_id(cls, v: str) -> str:
1021 return _check_genesis_id("proposal_id", v)
1022
1023 class ProposalListResponse(CamelModel):
1024 """Cursor-paginated list of merge proposals for a repo.
1025
1026 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page.
1027 A null ``nextCursor`` means this is the last page. ``total`` reflects
1028 the count of all matching proposals regardless of the current page.
1029 The ``Link: <url>; rel="next"`` response header carries the same signal
1030 for HTTP-native clients.
1031 """
1032
1033 proposals: list[ProposalResponse]
1034 total: int = Field(0, ge=0, description="Total matching merge proposals across all pages")
1035 next_cursor: str | None = Field(
1036 None,
1037 description=(
1038 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
1039 "Null when this is the last page."
1040 ),
1041 )
1042
1043 class ProposalMergeRequest(CamelModel):
1044 """Body for POST /musehub/repos/{repo_id}/proposals/{proposal_id}/merge."""
1045
1046 merge_strategy: str = Field(
1047 "merge_commit",
1048 pattern="^(merge_commit|squash|rebase)$",
1049 description="Merge strategy: 'merge_commit' (default), 'squash', or 'rebase'",
1050 )
1051
1052 class ProposalDiffDimensionScore(CamelModel):
1053 """Per-dimension change score between the from_branch and to_branch of a merge proposal.
1054
1055 Used by agents to determine which areas changed most significantly before
1056 deciding whether to approve or request changes.
1057 Scores are Jaccard divergence in [0.0, 1.0]: 0 = identical, 1 = completely different.
1058 """
1059
1060 dimension: str = Field(
1061 ...,
1062 description="Code dimension: interface | data | logic | tests | infrastructure",
1063 examples=["interface"],
1064 )
1065 score: float = Field(..., ge=0.0, le=1.0, description="Divergence magnitude [0.0, 1.0]")
1066 level: str = Field(..., description="Human-readable level: NONE | LOW | MED | HIGH")
1067 delta_label: str = Field(
1068 ...,
1069 description="Formatted delta label for diff badge, e.g. '+2.3' or 'unchanged'",
1070 )
1071 description: str = Field(..., description="Human-readable summary of what changed in this dimension")
1072 from_branch_commits: int = Field(..., description="Commits in from_branch touching this dimension")
1073 to_branch_commits: int = Field(..., description="Commits in to_branch touching this dimension")
1074
1075 class ProposalDiffResponse(CamelModel):
1076 """Divergence diff between the from_branch and to_branch of a merge proposal.
1077
1078 Returned by ``GET /api/repos/{repo_id}/proposals/{proposal_id}/diff``.
1079 Consumed by the merge proposal detail page to render dimension badges and
1080 divergence scores. Also consumed by AI agents to reason about impact before merging.
1081
1082 ``overall_score`` is in [0.0, 1.0]; multiply by 100 for a percentage.
1083 ``common_ancestor`` is the merge-base commit ID, or None if histories diverged.
1084 """
1085
1086 proposal_id: str = Field(..., description="The merge proposal being inspected")
1087 repo_id: str = Field(..., description="The repository containing the merge proposal")
1088 from_branch: str = Field(..., description="Source branch name")
1089 to_branch: str = Field(..., description="Target branch name")
1090 dimensions: list[ProposalDiffDimensionScore] = Field(
1091 ..., description="Per-dimension divergence scores (always five entries)"
1092 )
1093 overall_score: float | None = Field(None, ge=0.0, le=1.0, description="Mean of all dimension scores in [0.0, 1.0]")
1094 common_ancestor: str | None = Field(
1095 None, description="Merge-base commit ID; None if no common ancestor"
1096 )
1097 affected_sections: list[str] = Field(
1098 default_factory=list,
1099 description="Musical section names (e.g. Bridge, Chorus) mentioned in commit messages",
1100 )
1101
1102 class ProposalMergeResponse(CamelModel):
1103 """Confirmation that a merge proposal was merged."""
1104
1105 merged: bool = Field(..., description="True when the merge succeeded", examples=[True])
1106 merge_commit_id: str = Field(..., description="The new merge commit ID", examples=["c9d8e7f6a5b4"])
1107
1108 # ── Proposal review comment models ───────────────────────────────────────────────────
1109
1110 class ProposalCommentCreate(CamelModel):
1111 """Body for POST /musehub/repos/{repo_id}/proposals/{proposal_id}/comments.
1112
1113 ``target_type`` selects the granularity of the musical annotation:
1114 - ``general`` — whole proposal, no positional context
1115 - ``track`` — a named instrument track (supply ``target_track``)
1116 - ``region`` — beat range within a track (supply track + beat_start/end)
1117 - ``note`` — single note event (supply track + beat_start + note_pitch)
1118
1119 ``body`` supports Markdown so reviewers can format code-fence chord charts,
1120 lists of suggested edits, etc.
1121 """
1122
1123 body: str = Field(
1124 ...,
1125 min_length=1,
1126 max_length=10_000,
1127 description="Review comment body (Markdown)",
1128 examples=["The bass line in beats 16-24 feels rhythmically stiff — try adding some swing."],
1129 )
1130 target_type: str = Field(
1131 "general",
1132 pattern="^(general|track|region|note)$",
1133 description="Comment target granularity",
1134 examples=["region"],
1135 )
1136 target_track: str | None = Field(
1137 None,
1138 max_length=255,
1139 description="Instrument track name for track/region/note targets",
1140 examples=["bass"],
1141 )
1142 target_beat_start: float | None = Field(
1143 None,
1144 ge=0,
1145 description="First beat of the targeted region (inclusive)",
1146 examples=[16.0],
1147 )
1148 target_beat_end: float | None = Field(
1149 None,
1150 ge=0,
1151 description="Last beat of the targeted region (exclusive)",
1152 examples=[24.0],
1153 )
1154 target_note_pitch: int | None = Field(
1155 None,
1156 ge=0,
1157 le=127,
1158 description="MIDI pitch (0-127) for note-level targets",
1159 examples=[46],
1160 )
1161 parent_comment_id: str | None = Field(
1162 None,
1163 description="Genesis-addressed ID of the parent comment when creating a threaded reply",
1164 )
1165 symbol_address: str | None = Field(
1166 None,
1167 max_length=512,
1168 description=(
1169 "Symbol address to anchor this comment to (e.g. 'auth.py::AuthService.login'). "
1170 "Binds the comment to a specific named symbol in the Symbol Delta. "
1171 "Takes precedence over target_type for code-domain proposals."
1172 ),
1173 examples=["core/engine.py::Engine.process"],
1174 )
1175
1176 @field_validator("parent_comment_id")
1177 @classmethod
1178 def _check_parent_comment_id(cls, v: str | None) -> str | None:
1179 if v is not None:
1180 _check_genesis_id("parent_comment_id", v)
1181 return v
1182
1183 class ProposalCommentResponse(CamelModel):
1184 """Wire representation of a single proposal review comment."""
1185
1186 comment_id: str = Field(..., description="Internal sha256 genesis hash for this comment")
1187 proposal_id: str = Field(..., description="Proposal this comment belongs to")
1188 author: str = Field(..., description="Display name / MSign handle of the comment author")
1189 body: str = Field(..., description="Review body (Markdown)")
1190 target_type: str = Field(..., description="'general', 'track', 'region', or 'note'")
1191 target_track: str | None = Field(None, description="Instrument track name when targeted")
1192 target_beat_start: float | None = Field(None, description="Region start beat (inclusive)")
1193 target_beat_end: float | None = Field(None, description="Region end beat (exclusive)")
1194 target_note_pitch: int | None = Field(None, description="MIDI pitch for note-level targets")
1195 parent_comment_id: str | None = Field(None, description="Parent comment ID for threaded replies")
1196 symbol_address: str | None = Field(None, description="Symbol address anchor, if set")
1197 created_at: datetime = Field(..., description="Comment creation timestamp (ISO-8601 UTC)")
1198 replies: list[ProposalCommentResponse] = Field(
1199 default_factory=list,
1200 description="Nested replies to this comment (only populated on top-level comments)",
1201 )
1202
1203 @field_validator("comment_id", "proposal_id")
1204 @classmethod
1205 def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str:
1206 return _check_genesis_id(getattr(info, "field_name", "id"), v)
1207
1208 class ProposalCommentListResponse(CamelModel):
1209 """Cursor-paginated list of review comments for a merge proposal.
1210
1211 ``comments`` contains only top-level comments; each carries a ``replies``
1212 list with its direct children, sorted chronologically. This two-level
1213 structure covers all current threading requirements without recursive fetches.
1214 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page of top-level
1215 comments. A null ``nextCursor`` means this is the last page.
1216 """
1217
1218 comments: list[ProposalCommentResponse] = Field(
1219 default_factory=list,
1220 description="Top-level review comments with nested replies",
1221 )
1222 total: int = Field(0, ge=0, description="Total number of comments (all levels)")
1223 next_cursor: str | None = Field(
1224 None,
1225 description=(
1226 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
1227 "Null when this is the last page."
1228 ),
1229 )
1230
1231 # Rebuild the model to resolve the forward reference in ProposalCommentResponse.replies
1232 ProposalCommentResponse.model_rebuild()
1233
1234 # ── Proposal reviewer / review models ───────────────────────────────────────────────
1235
1236 class ProposalReviewerRequest(CamelModel):
1237 """Body for POST /musehub/repos/{repo_id}/proposals/{proposal_id}/reviewers.
1238
1239 Requests a review from one or more users. Each username is added as a
1240 ``pending`` review row. Duplicate requests for the same reviewer are
1241 idempotent — the state is not reset if the reviewer already submitted.
1242 """
1243
1244 reviewers: list[str] = Field(
1245 ...,
1246 min_length=1,
1247 description="List of usernames to request reviews from",
1248 examples=[["alice", "bob"]],
1249 )
1250
1251 class ProposalReviewResponse(CamelModel):
1252 """Wire representation of a single proposal review.
1253
1254 ``state`` reflects the current disposition of the reviewer:
1255 - ``pending`` — review requested, not yet submitted
1256 - ``approved`` — reviewer approved the changes
1257 - ``changes_requested`` — reviewer blocked the merge pending fixes
1258 - ``dismissed`` — a previous review was dismissed by the merge proposal author
1259
1260 ``submitted_at`` is ``None`` while the review is in ``pending`` state.
1261 """
1262
1263 id: str = Field(..., description="Internal genesis-addressed hash for this review row")
1264 proposal_id: str = Field(..., description="Proposal this review belongs to")
1265 reviewer_username: str = Field(..., description="Username of the reviewer")
1266 state: str = Field(
1267 ...,
1268 description="Review state: pending | approved | changes_requested | dismissed",
1269 examples=["approved"],
1270 )
1271 body: str | None = Field(None, description="Review comment body (Markdown); null for bare assignments")
1272 submitted_at: datetime | None = Field(None, description="UTC timestamp when the review was submitted")
1273 created_at: datetime = Field(..., description="Row creation timestamp (ISO-8601 UTC)")
1274
1275 @field_validator("id", "proposal_id")
1276 @classmethod
1277 def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str:
1278 return _check_genesis_id(getattr(info, "field_name", "id"), v)
1279
1280 class ProposalReviewListResponse(CamelModel):
1281 """Cursor-paginated list of reviews for a merge proposal.
1282
1283 Used by the merge proposal detail page review panel and by AI agents
1284 evaluating merge readiness. Includes both pending assignments and
1285 submitted reviews. Pass ``nextCursor`` as ``?cursor=`` to advance.
1286 A null ``nextCursor`` means this is the last page.
1287 """
1288
1289 reviews: list[ProposalReviewResponse] = Field(
1290 default_factory=list,
1291 description="All review rows for this merge proposal (pending and submitted)",
1292 )
1293 total: int = Field(0, ge=0, description="Total number of review rows")
1294 next_cursor: str | None = Field(
1295 None,
1296 description=(
1297 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
1298 "Null when this is the last page."
1299 ),
1300 )
1301
1302 class ProposalReviewCreate(CamelModel):
1303 """Body for POST /musehub/repos/{repo_id}/proposals/{proposal_id}/reviews.
1304
1305 Submits a formal review for the authenticated user. If the user was
1306 previously assigned as a reviewer, the existing ``pending`` row is updated
1307 in-place. If no prior row exists, a new one is created.
1308
1309 ``event`` governs the new review state:
1310 - ``approve`` → state = approved
1311 - ``request_changes`` → state = changes_requested
1312 - ``comment`` → state = pending (body-only feedback, no verdict)
1313 """
1314
1315 event: str = Field(
1316 ...,
1317 pattern="^(approve|request_changes|comment)$",
1318 description="Review event: approve | request_changes | comment",
1319 examples=["approve"],
1320 )
1321 body: str = Field(
1322 "",
1323 max_length=10_000,
1324 description="Review body (Markdown). Required when event='request_changes'.",
1325 examples=["Sounds great — the harmonic transitions in the bridge are exactly right."],
1326 )
1327
1328 # ── Proposal list enrichment models ──────────────────────────────────────────
1329
1330 class ProposalListEntry(CamelModel):
1331 """Enriched row for the proposals list view.
1332
1333 Produced by ``enrich_proposal_list_entry()`` from a single ``MusehubProposal``
1334 ORM row combined with pre-fetched review and risk data. All computed fields
1335 are derived server-side; nothing here comes from the client.
1336
1337 Fields prefixed ``domain_`` are per-domain dicts keyed by domain name
1338 (e.g. ``"code"``, ``"midi"``). They are only meaningful for domains present
1339 in ``active_domains``; callers should check membership before accessing.
1340
1341 Invariants:
1342 - ``is_blocked`` is always ``len(blocked_by) > 0``
1343 - ``aggregate_risk_score`` is always in ``[0.0, 1.0]``
1344 - ``active_domains`` never contains a domain whose risk score is 0.0
1345 - ``all_merge_conditions_met`` is ``False`` when
1346 ``approval_count < required_approvals``
1347 - ``payment_settling`` is ``True`` only when ``state == "settling"``
1348 and ``"pay" in active_domains``
1349 """
1350
1351 # ── Core ─────────────────────────────────────────────────────────────────
1352 proposal_id: str = Field(..., description="Full sha256 proposal ID")
1353 proposal_number: int = Field(..., description="Per-repo sequential number (1-based)")
1354 title: str = Field(..., description="Proposal title, truncated to 80 chars in list view")
1355 state: str = Field(..., description="7-state machine value (open, in_review, approved, drafting, settling, merged, abandoned)")
1356 proposal_type: str = Field("state_merge", description="Proposal type label (e.g. state_merge, midi_evolution)")
1357 from_branch: str = Field(..., description="Source branch")
1358 to_branch: str = Field(..., description="Target branch")
1359 author: str = Field("", description="Author handle")
1360 author_type: str = Field("human", description="'human' | 'agent' | 'org' — resolved from MusehubIdentity")
1361 created_at: datetime = Field(..., description="Creation timestamp (UTC)")
1362 merged_at: datetime | None = Field(None, description="Merge timestamp; None while open")
1363 is_draft: bool = Field(False, description="True when proposal is in drafting state")
1364
1365 # ── Dimensional activity ──────────────────────────────────────────────────
1366 active_domains: list[str] = Field(
1367 default_factory=list,
1368 description="Domains with non-zero risk or actual diff content (never contains a domain with risk=0.0)",
1369 )
1370 domain_risk: dict[str, float] = Field(
1371 default_factory=dict,
1372 description="Per-domain risk score in [0.0, 1.0], keyed by domain name",
1373 )
1374 domain_risk_band: dict[str, str] = Field(
1375 default_factory=dict,
1376 description="Per-domain risk band ('critical'|'high'|'medium'|'low'), keyed by domain name",
1377 )
1378 aggregate_risk_score: float = Field(
1379 0.0,
1380 ge=0.0,
1381 le=1.0,
1382 description="Weighted mean of domain_risk values across active domains",
1383 )
1384 aggregate_risk_band: str = Field(
1385 "none",
1386 description="'critical' (≥0.75) | 'high' (≥0.5) | 'medium' (≥0.25) | 'low' (>0) | 'none' (0.0)",
1387 )
1388
1389 # ── Review status ─────────────────────────────────────────────────────────
1390 approval_count: int = Field(0, ge=0, description="Number of distinct approved reviews")
1391 required_approvals: int = Field(
1392 2,
1393 ge=0,
1394 description="Approvals needed to satisfy merge conditions; falls back to repo default (2) when merge_conditions is null",
1395 )
1396 domains_approved: list[str] = Field(
1397 default_factory=list,
1398 description="Domains that have received at least one approved review",
1399 )
1400 domains_pending_review: list[str] = Field(
1401 default_factory=list,
1402 description="Active domains that need approval but do not yet have it",
1403 )
1404 all_merge_conditions_met: bool = Field(
1405 False,
1406 description="True iff every merge condition passes (approval count, no breakage, etc.)",
1407 )
1408
1409 # ── Dependency position ───────────────────────────────────────────────────
1410 blocked_by: list[int] = Field(
1411 default_factory=list,
1412 description="proposal_numbers this depends on that are not yet merged",
1413 )
1414 blocks: list[int] = Field(
1415 default_factory=list,
1416 description="proposal_numbers that depend on this proposal",
1417 )
1418 is_blocked: bool = Field(False, description="Convenience: len(blocked_by) > 0")
1419
1420 # ── Code domain summary ───────────────────────────────────────────────────
1421 symbols_changed: int = Field(0, ge=0, description="Symbol addresses changed in this proposal")
1422 breakage_count: int = Field(0, ge=0, description="Structural breakage events detected")
1423 test_gap_count: int = Field(0, ge=0, description="Symbols changed without test coverage")
1424 touched_symbols_preview: list[str] = Field(
1425 default_factory=list,
1426 description="Top 3 symbol addresses for hover tooltip (truncated from touched_symbols)",
1427 )
1428
1429 # ── MIDI domain summary ───────────────────────────────────────────────────
1430 midi_tracks_changed: int = Field(0, ge=0, description="Number of MIDI tracks modified")
1431 midi_notes_delta: int = Field(0, description="Net note count delta (positive = added)")
1432 harmonic_tension_delta: float | None = Field(
1433 None,
1434 description="Change in harmonic tension score; None when not computable",
1435 )
1436
1437 # ── Payment domain summary ────────────────────────────────────────────────
1438 payment_claim_count: int = Field(0, ge=0, description="Number of micropayment claims in this proposal")
1439 payment_ledger_delta_nano: int = Field(0, description="Net ledger delta in nanoMUSE")
1440 payment_avax_address: str | None = Field(None, description="AVAX settlement address when relevant")
1441 payment_settling: bool = Field(
1442 False,
1443 description="True when state='settling' and 'pay' in active_domains",
1444 )
1445
1446 # ── Merge strategy ────────────────────────────────────────────────────────
1447 merge_strategy: str = Field(
1448 "state_overlay",
1449 description="Conflict resolution strategy for this proposal",
1450 )
1451
1452 # ── Agent metadata ────────────────────────────────────────────────────────
1453 agent_model: str | None = Field(None, description="Model ID when author_type='agent'")
1454 agent_spawned_by: str | None = Field(None, description="Parent human handle that spawned this agent")
1455
1456 # ── Simulation summary (prefetched — zero extra I/O per row) ─────────────
1457 simulation_conflict_count: int | None = Field(
1458 None,
1459 description=(
1460 "Conflict count from the latest conflict_scan simulation. "
1461 "None when no simulation has been run."
1462 ),
1463 )
1464
1465
1466 class ProposalListFilters(CamelModel):
1467 """Query parameters for the proposals list page and rows fragment.
1468
1469 All fields have safe defaults so the page renders correctly with zero
1470 query params. ``state`` defaults to ``"open"`` (the most common view).
1471 ``limit`` is capped at 100 server-side; requests above that get a 422.
1472
1473 Sort values:
1474 newest: created_at DESC (default — most recent first)
1475 oldest: created_at ASC
1476 risk_desc: aggregate_risk_score DESC — critical proposals first
1477 risk_asc: aggregate_risk_score ASC — safest proposals first
1478 merge_ready_first: all_merge_conditions_met=True first, then risk_desc
1479
1480 ``domain`` is repeatable (``?domain=code&domain=midi`` → proposals touching
1481 *either* domain). An empty list means all domains. ``proposal_type`` is
1482 likewise repeatable.
1483
1484 ``assigned_reviewer`` filters to proposals where the given handle has a
1485 pending review request. Validated to slug characters only before any DB access.
1486 """
1487
1488 state: str = Field(
1489 "open",
1490 pattern="^(open|in_review|approved|drafting|settling|merged|closed|abandoned|all)$",
1491 description="State filter; 'all' returns every state",
1492 )
1493 proposal_type: list[str] | None = Field(
1494 None,
1495 description="Repeatable proposal type filter (e.g. state_merge, midi_evolution)",
1496 )
1497 domain: list[str] | None = Field(
1498 None,
1499 description="Repeatable domain filter — proposals touching any of these domains",
1500 )
1501 risk_band: list[str] | None = Field(
1502 None,
1503 description="Repeatable risk band filter (critical|high|medium|low)",
1504 )
1505 author_type: str = Field(
1506 "all",
1507 pattern="^(human|agent|org|all)$",
1508 description="Filter by author identity type",
1509 )
1510 is_blocked: bool | None = Field(
1511 None,
1512 description="None = all, True = blocked only, False = unblocked only",
1513 )
1514 is_draft: bool | None = Field(
1515 None,
1516 description="None = all, True = drafts only, False = non-draft only",
1517 )
1518 merge_strategy: list[str] | None = Field(
1519 None,
1520 description="Repeatable merge strategy filter (e.g. state_overlay, state_weave, phased)",
1521 )
1522 assigned_reviewer: str | None = Field(
1523 None,
1524 pattern=r"^[a-zA-Z0-9_-]{1,64}$",
1525 description="Filter to proposals where this handle has a pending review request",
1526 )
1527 limit: int = Field(20, ge=1, le=100, description="Page size (max 100)")
1528 cursor: str | None = Field(None, description="Opaque pagination cursor from previous response")
1529 sort: str = Field(
1530 "newest",
1531 pattern="^(newest|oldest|risk_desc|risk_asc|merge_ready_first)$",
1532 description="Sort order for the proposal list",
1533 )
1534
1535
1536 class DomainHeatEntry(CamelModel):
1537 """Per-domain activity metrics for the domain heat bar.
1538
1539 ``count`` is the number of proposals in the requested state that touch this
1540 domain. ``avg_risk`` is the arithmetic mean of non-zero risk_score values
1541 for those proposals; it is 0.0 when count is 0.
1542 """
1543
1544 count: int = Field(0, ge=0, description="Proposals touching this domain")
1545 avg_risk: float = Field(0.0, ge=0.0, le=1.0, description="Mean risk score across those proposals")
1546
1547
1548 class DomainHeatResponse(CamelModel):
1549 """Domain heat bar data for the proposals list page header.
1550
1551 Returned by ``GET /api/repos/{repo_id}/proposals/heat``.
1552 ``domains`` maps domain name (e.g. ``"code"``) to its heat entry.
1553 Domains with zero matching proposals are omitted from the dict.
1554 """
1555
1556 domains: dict[str, DomainHeatEntry] = Field(
1557 default_factory=dict,
1558 description="Per-domain heat entries; absent key means zero proposals",
1559 )
1560 total_open: int = Field(0, ge=0, description="Total proposals in the queried state")
1561
1562
1563 class MergeReadinessResponse(CamelModel):
1564 """Merge readiness bucketing for the proposals list sidebar widget.
1565
1566 Returned by ``GET /api/repos/{repo_id}/proposals/readiness``.
1567
1568 Categories:
1569 ready: all_merge_conditions_met=True and is_blocked=False
1570 blocked: is_blocked=True (regardless of condition status)
1571 settling: state='settling'
1572 needs_review: not blocked, not settling, conditions not fully met
1573
1574 Each list contains proposal numbers (integers), not IDs.
1575 """
1576
1577 ready: list[int] = Field(default_factory=list, description="Proposal numbers that can be merged right now")
1578 blocked: list[int] = Field(default_factory=list, description="Proposal numbers blocked by unmerged dependencies")
1579 settling: list[int] = Field(default_factory=list, description="Proposal numbers awaiting on-chain confirmation")
1580 needs_review: list[int] = Field(default_factory=list, description="Proposal numbers pending domain review")
1581
1582
1583 # ── Proposal simulation models ────────────────────────────────────────────────
1584
1585 class SimulationType(str, Enum):
1586 """Three simulation types supported by the proposal simulation engine.
1587
1588 conflict_scan — identify files / domains that will conflict at merge time
1589 risk_projection — project post-merge dimensional risk scores per domain
1590 dependency_order — Kahn topological sort of the dependency DAG for this proposal
1591 """
1592
1593 CONFLICT_SCAN = "conflict_scan"
1594 RISK_PROJECTION = "risk_projection"
1595 DEPENDENCY_ORDER = "dependency_order"
1596
1597
1598 class SimulationResponse(CamelModel):
1599 """Cached simulation result for a proposal.
1600
1601 Returned by:
1602 POST /repos/{repo_id}/proposals/{proposal_id}/simulations/{simulation_type}
1603 GET /repos/{repo_id}/proposals/{proposal_id}/simulations/{simulation_type}
1604
1605 ``result`` schema depends on ``simulation_type``:
1606 conflict_scan → ConflictScanPayload keys
1607 risk_projection → RiskProjectionPayload keys
1608 dependency_order → DependencyOrderPayload keys
1609
1610 ``is_stale`` is True when from_branch has advanced since the simulation ran.
1611 """
1612
1613 simulation_id: str = Field(..., description="sha256: content-addressed simulation ID")
1614 proposal_id: str = Field(..., description="Proposal this simulation belongs to")
1615 simulation_type: str = Field(..., description="One of: conflict_scan | risk_projection | dependency_order")
1616 result: PydanticJson = Field(default_factory=dict, description="Simulation payload; schema determined by simulation_type")
1617 is_stale: bool = Field(False, description="True when from_branch has advanced since this simulation ran")
1618 from_branch_commit_id: str = Field("", description="from_branch tip at the time of the simulation run")
1619 duration_ms: int = Field(0, ge=0, description="Wall-clock milliseconds the simulation took to run")
1620 created_at: datetime = Field(..., description="UTC timestamp when the simulation was last run")
1621 expires_at: datetime | None = Field(None, description="Optional TTL — null means no expiry")
1622
1623
1624 class SimulationListResponse(CamelModel):
1625 """All simulations run for a single proposal."""
1626
1627 simulations: list[SimulationResponse] = Field(default_factory=list)
1628 total: int = Field(0, ge=0)
1629
1630
1631 # ── Release models ────────────────────────────────────────────────────────────
1632
1633 class ReleaseCreate(CamelModel):
1634 """Body for POST /musehub/repos/{repo_id}/releases.
1635
1636 ``tag`` must be unique per repo and must be a valid semver string
1637 (e.g. "v1.2.3", "v2.0.0-beta.1"). ``commit_id`` pins the release to a
1638 specific commit snapshot. ``channel`` replaces the boolean ``is_prerelease``
1639 flag with a named distribution tier.
1640 """
1641
1642 tag: str = Field(
1643 ..., min_length=1, max_length=100, description="Semver tag, e.g. 'v1.2.3'", examples=["v1.2.3"]
1644 )
1645 title: str = Field(
1646 "", max_length=500, description="Release title", examples=["Summer Sessions 2024 — Final Mix"]
1647 )
1648 body: str = Field(
1649 "",
1650 max_length=10_000,
1651 description="Release notes (Markdown)",
1652 examples=["## Summer Sessions 2024\n\nFinal arrangement with full brass section and 132 BPM tempo."],
1653 )
1654 commit_id: str | None = Field(
1655 None, description="Commit to pin this release to", examples=["a3f8c1d2e4b5"]
1656 )
1657 snapshot_id: str | None = Field(
1658 None, description="Snapshot ID for reproducible builds"
1659 )
1660 channel: str = Field(
1661 "stable",
1662 description="Distribution channel: stable | beta | alpha | nightly",
1663 examples=["stable"],
1664 )
1665 semver_major: int = Field(0, ge=0)
1666 semver_minor: int = Field(0, ge=0)
1667 semver_patch: int = Field(0, ge=0)
1668 semver_pre: str = Field("", max_length=255, description="Pre-release label, e.g. 'beta.1'")
1669 semver_build: str = Field("", max_length=255, description="Build metadata, e.g. '20250101'")
1670 agent_id: str = Field("", max_length=255)
1671 model_id: str = Field("", max_length=255)
1672 changelog: list[ChangelogEntryResponse] = Field(
1673 default_factory=list, description="Auto-generated changelog entries"
1674 )
1675 is_draft: bool = Field(False, description="Save as draft — not yet publicly visible")
1676 gpg_signature: str | None = Field(
1677 None,
1678 description="ASCII-armoured GPG signature for the tag object; omit when unsigned",
1679 )
1680 semantic_report: SemanticReleaseReportResponse | None = Field(
1681 None,
1682 description="Semantic analysis blob computed by the Muse CLI at push time.",
1683 )
1684
1685 class ReleaseDownloadUrls(CamelModel):
1686 """Structured download package URLs for a release.
1687
1688 Each field is either a URL string or None if the package is not available.
1689 ``metadata`` is a JSON manifest with release info.
1690 """
1691
1692 metadata: str | None = None
1693
1694 class ReleaseResponse(CamelModel):
1695 """Wire representation of a MuseHub release.
1696
1697 ``channel`` surfaces the distribution tier (stable | beta | alpha | nightly).
1698 ``is_draft`` hides the release from public listings until published.
1699 ``gpg_signature`` is None when unsigned; a non-empty string triggers the
1700 verified badge in the UI.
1701 ``semantic_report`` is the Muse CLI analysis attached at push time; ``None``
1702 when the release was pushed with ``--no-analysis`` or by an older CLI.
1703 """
1704
1705 release_id: str
1706 tag: str
1707 title: str = ""
1708 body: str = ""
1709 commit_id: str | None = None
1710 snapshot_id: str | None = None
1711 channel: str = "stable"
1712 semver_major: int = 0
1713 semver_minor: int = 0
1714 semver_patch: int = 0
1715 semver_pre: str = ""
1716 semver_build: str = ""
1717 download_urls: ReleaseDownloadUrls
1718 author: str = ""
1719 agent_id: str = ""
1720 model_id: str = ""
1721 changelog: list[ChangelogEntryResponse] = Field(default_factory=list)
1722 is_prerelease: bool = False
1723 is_draft: bool = False
1724 gpg_signature: str | None = None
1725 semantic_report: SemanticReleaseReportResponse | None = None
1726 created_at: datetime
1727
1728 @field_validator("release_id")
1729 @classmethod
1730 def _check_release_id(cls, v: str) -> str:
1731 return _check_genesis_id("release_id", v)
1732
1733 @model_validator(mode="after")
1734 def _derive_is_prerelease(self) -> "ReleaseResponse":
1735 """Derive is_prerelease from channel: non-stable channels are pre-releases."""
1736 self.is_prerelease = self.channel != "stable"
1737 return self
1738
1739 class ReleaseListResponse(CamelModel):
1740 """Cursor-paginated list of releases for a repo (newest first).
1741
1742 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page.
1743 A null ``nextCursor`` means this is the last page. ``total`` reflects
1744 the count of all matching releases regardless of the current page.
1745 The ``Link: <url>; rel="next"`` response header carries the same signal
1746 for HTTP-native clients.
1747 """
1748
1749 releases: list[ReleaseResponse]
1750 total: int = Field(0, ge=0, description="Total matching releases across all pages")
1751 next_cursor: str | None = Field(
1752 None,
1753 description=(
1754 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
1755 "Null when this is the last page."
1756 ),
1757 )
1758
1759 # ── Release asset models ───────────────────────────────────────────────────
1760
1761 class ReleaseAssetCreate(CamelModel):
1762 """Body for POST /musehub/repos/{repo_id}/releases/{tag}/assets.
1763
1764 ``name`` is the filename shown in the UI (e.g. "summer-v1.0.mid").
1765 ``download_url`` is the pre-signed or CDN URL from which clients
1766 download the artifact; Muse stores it verbatim.
1767 """
1768
1769 name: str = Field(
1770 ..., min_length=1, max_length=500, description="Filename shown in the UI"
1771 )
1772 label: str = Field(
1773 "",
1774 max_length=255,
1775 description="Optional human-readable label, e.g. 'MIDI Bundle'",
1776 )
1777 content_type: str = Field(
1778 "",
1779 max_length=128,
1780 description="MIME type, e.g. 'audio/midi', 'application/zip'",
1781 )
1782 size: int = Field(
1783 0, ge=0, description="File size in bytes; 0 when unknown"
1784 )
1785 download_url: str = Field(
1786 ..., min_length=1, max_length=2048, description="Direct download URL for the artifact"
1787 )
1788
1789 class ReleaseAssetResponse(CamelModel):
1790 """Wire representation of a single release asset."""
1791
1792 asset_id: str = Field(..., description="Internal genesis-addressed hash for this asset")
1793 release_id: str = Field(..., description="Genesis-addressed hash of the owning release")
1794 name: str = Field(..., description="Filename shown in the UI")
1795 label: str = Field("", description="Optional human-readable label")
1796 content_type: str = Field("", description="MIME type of the artifact")
1797 size: int = Field(0, ge=0, description="File size in bytes; 0 when unknown")
1798 download_url: str = Field(..., description="Direct download URL")
1799 download_count: int = Field(0, ge=0, description="Number of times the asset has been downloaded")
1800 created_at: datetime = Field(..., description="Asset creation timestamp (ISO-8601 UTC)")
1801
1802 @field_validator("asset_id", "release_id")
1803 @classmethod
1804 def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str:
1805 return _check_genesis_id(getattr(info, "field_name", "id"), v)
1806
1807 class ReleaseAssetListResponse(CamelModel):
1808 """Cursor-paginated list of assets attached to a release.
1809
1810 Agents use this to surface per-asset download counts and direct download
1811 URLs on the release detail page without re-fetching the full release.
1812 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page.
1813 A null ``nextCursor`` means this is the last page.
1814 """
1815
1816 release_id: str
1817 tag: str
1818 assets: list[ReleaseAssetResponse]
1819 total: int = Field(0, ge=0, description="Total assets attached to this release")
1820 next_cursor: str | None = Field(
1821 None,
1822 description=(
1823 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
1824 "Null when this is the last page."
1825 ),
1826 )
1827
1828 class ReleaseAssetDownloadCount(CamelModel):
1829 """Per-asset download count entry in a release download stats response."""
1830
1831 asset_id: str = Field(..., description="Internal sha256 genesis hash for the asset")
1832 name: str = Field(..., description="Filename shown in the UI")
1833 label: str = Field("", description="Optional human-readable label")
1834 download_count: int = Field(0, ge=0, description="Number of times this asset has been downloaded")
1835
1836 class ReleaseDownloadStatsResponse(CamelModel):
1837 """Download counts per asset for a single release.
1838
1839 Returned by ``GET /repos/{repo_id}/releases/{tag}/downloads``.
1840 ``total_downloads`` is the sum of ``download_count`` across all assets,
1841 providing a quick headline metric without client-side aggregation.
1842 """
1843
1844 release_id: str = Field(..., description="sha256 genesis hash of the release")
1845 tag: str = Field(..., description="Version tag of the release")
1846 assets: list[ReleaseAssetDownloadCount] = Field(
1847 default_factory=list,
1848 description="Per-asset download counts; empty when no assets have been attached",
1849 )
1850 total_downloads: int = Field(
1851 0, ge=0, description="Sum of download_count across all assets"
1852 )
1853
1854 # ── Credits models ────────────────────────────────────────────────────────────
1855
1856 class ContributorCredits(CamelModel):
1857 """Wire representation of a single contributor's credit record.
1858
1859 Aggregated from commit history -- one record per unique author string.
1860 Contribution types are inferred from commit message keywords so that an
1861 agent or a human can understand each collaborator's role at a glance.
1862 """
1863
1864 author: str
1865 session_count: int
1866 contribution_types: list[str]
1867 first_active: datetime
1868 last_active: datetime
1869
1870 class CreditsResponse(CamelModel):
1871 """Wire representation of the full credits roll for a repo.
1872
1873 Returned by ``GET /api/repos/{repo_id}/credits``.
1874 The ``sort`` field echoes back the sort order applied to the list.
1875 An empty ``contributors`` list means no commits have been pushed yet.
1876 """
1877
1878 repo_id: str
1879 contributors: list[ContributorCredits]
1880 sort: str
1881 total_contributors: int
1882
1883 # ── Object metadata model ─────────────────────────────────────────────────────
1884
1885 class ObjectMetaResponse(CamelModel):
1886 """Wire representation of a stored artifact -- metadata only, no content bytes.
1887
1888 Returned by GET /musehub/repos/{repo_id}/objects. Use the ``/content``
1889 sub-resource to download the raw bytes. The ``path`` field retains the
1890 client-supplied relative path hint (e.g. "piano-roll.webp") and is
1891 the primary signal for choosing display treatment (.webp → img, etc.).
1892 """
1893
1894 object_id: str
1895 path: str
1896 size_bytes: int
1897 created_at: datetime
1898
1899 class ObjectMetaListResponse(CamelModel):
1900 """List of artifact metadata for a repo."""
1901
1902 objects: list[ObjectMetaResponse]
1903
1904 # ── Timeline models ───────────────────────────────────────────────────────────
1905
1906 class TimelineCommitEvent(CamelModel):
1907 """A commit plotted as a point on the timeline.
1908
1909 Every pushed commit becomes a commit event regardless of its message content.
1910 The ``commit_id`` is the canonical identifier for audio-preview lookup and
1911 deep-linking to the commit detail page.
1912 """
1913
1914 event_type: str = "commit"
1915 commit_id: str
1916 branch: str
1917 message: str
1918 author: str
1919 timestamp: datetime
1920 parent_ids: list[str]
1921
1922 class TimelineResponse(CamelModel):
1923 """Chronological timeline of commits for a repo.
1924
1925 Returns commits oldest-first for temporal rendering. Use ``total_commits``
1926 to show pagination state when the history is truncated.
1927 """
1928
1929 commits: list[TimelineCommitEvent]
1930 total_commits: int
1931
1932 # ── Divergence visualization models ───────────────────────────────────────────
1933
1934 class DivergenceDimensionResponse(CamelModel):
1935 """Wire representation of divergence scores for a single musical dimension.
1936
1937 Mirrors :class:`musehub.services.musehub_divergence.MuseHubDimensionDivergence`
1938 for JSON serialization. AI agents consume this to decide which dimension
1939 of a branch needs creative attention before merging.
1940 """
1941
1942 dimension: str
1943 level: str
1944 score: float
1945 description: str
1946 branch_a_commits: int
1947 branch_b_commits: int
1948
1949 class DivergenceResponse(CamelModel):
1950 """Full musical divergence report between two MuseHub branches.
1951
1952 Returned by ``GET /musehub/repos/{repo_id}/divergence``. Contains five
1953 per-dimension scores (melodic, harmonic, rhythmic, structural, dynamic)
1954 and an overall score computed as the mean of those five scores.
1955
1956 The ``overall_score`` is in [0.0, 1.0]; multiply by 100 for a percentage.
1957 A score of 0.0 means identical, 1.0 means completely diverged.
1958 """
1959
1960 repo_id: str
1961 branch_a: str
1962 branch_b: str
1963 common_ancestor: str | None
1964 dimensions: list[DivergenceDimensionResponse]
1965 overall_score: float
1966
1967 # ── Commit diff summary models ─────────────────────────────────────────────────
1968
1969 class CommitDiffDimensionScore(CamelModel):
1970 """Per-dimension change score between a commit and its parent.
1971
1972 Scores are heuristic estimates derived from the commit message and metadata.
1973 They indicate *how much* each musical dimension changed in this commit.
1974 """
1975
1976 dimension: str = Field(
1977 ...,
1978 description="Musical dimension: harmonic | rhythmic | melodic | structural | dynamic",
1979 examples=["harmonic"],
1980 )
1981 score: float = Field(..., ge=0.0, le=1.0, description="Change magnitude [0.0, 1.0]")
1982 label: str = Field(..., description="Human-readable level: none | low | medium | high")
1983 color: str = Field(
1984 ...,
1985 description="CSS class hint for badge colour: dim-none | dim-low | dim-medium | dim-high",
1986 )
1987
1988 class CommitDiffSummaryResponse(CamelModel):
1989 """Multi-dimensional diff summary between a commit and its parent.
1990
1991 Returned by ``GET /api/repos/{repo_id}/commits/{commit_id}/diff-summary``.
1992 Consumed by the commit detail page to render dimension-change badges that help
1993 musicians understand *what* changed musically between two pushes.
1994 """
1995
1996 commit_id: str = Field(..., description="The commit being inspected")
1997 parent_id: str | None = Field(None, description="Parent commit ID; None for root commits")
1998 dimensions: list[CommitDiffDimensionScore] = Field(
1999 ..., description="Per-dimension change scores (always five entries)"
2000 )
2001 overall_score: float = Field(
2002 ..., ge=0.0, le=1.0, description="Mean across all five dimension scores"
2003 )
2004
2005 # ── Explore / Discover models ──────────────────────────────────────────────────
2006
2007 class ExploreRepoResult(CamelModel):
2008 """A public repo card shown on the explore/discover page.
2009
2010 Aggregated counts (commit_count) are computed at query time for
2011 efficient pagination and sorting.
2012
2013 ``owner`` and ``slug`` together form the /{owner}/{slug} canonical URL.
2014 """
2015
2016 repo_id: str
2017 name: str
2018 owner: str
2019 slug: str
2020 owner_user_id: str
2021 description: str
2022 tags: list[str]
2023 commit_count: int
2024 created_at: datetime
2025 pushed_at: datetime | None = None
2026
2027 # ── Profile models ────────────────────────────────────────────────────────────
2028
2029 class ProfileUpdateRequest(CamelModel):
2030 """Body for PUT /api/users/{username}.
2031
2032 All fields are optional -- send only the ones to change.
2033 ``is_verified`` and ``cc_license`` are intentionally excluded: they are
2034 set by the platform (not self-reported) when an archive upload is approved.
2035 """
2036
2037 display_name: str | None = Field(None, max_length=255, description="Human-readable display name")
2038 bio: str | None = Field(None, max_length=500, description="Short bio (Markdown supported)")
2039 avatar_url: str | None = Field(None, max_length=2048, description="Avatar image URL")
2040 location: str | None = Field(None, max_length=255, description="City or region")
2041 website_url: str | None = Field(None, max_length=2048, description="Personal website or project URL")
2042 social_url: str | None = Field(None, max_length=2048, description="Full URL to a social profile (e.g. https://x.com/handle)")
2043 pinned_repo_ids: list[str] | None = Field(
2044 None, max_length=6, description="Up to 6 repo_ids to pin on the profile page"
2045 )
2046
2047 class ProfileRepoSummary(CamelModel):
2048 """Compact repo summary shown on a user's profile page.
2049
2050 Includes the last-activity timestamp derived from the most recent commit.
2051 ``owner`` and ``slug`` form the /{owner}/{slug} canonical URL for the repo card.
2052 """
2053
2054 repo_id: str
2055 name: str
2056 owner: str
2057 slug: str
2058 visibility: str
2059 domain: str
2060 last_activity_at: datetime | None
2061 created_at: datetime
2062
2063 class ExploreResponse(CamelModel):
2064 """Cursor-paginated response from GET /api/discover/repos.
2065
2066 ``total`` reflects the full filtered result set size -- not just the current
2067 page. Pass ``nextCursor`` as ``?cursor=`` to advance to the next page.
2068 A null ``nextCursor`` means this is the last page.
2069 """
2070
2071 repos: list[ExploreRepoResult]
2072 total: int
2073 next_cursor: str | None = None
2074
2075 class ProfileResponse(CamelModel):
2076 """Full wire representation of a MuseHub user profile.
2077
2078 Returned by GET /api/users/{username}.
2079 ``repos`` contains only public repos when the caller is not the owner.
2080 ``session_credits`` is the total number of commits across all repos
2081 (a proxy for creative session activity).
2082
2083 CC attribution fields added:
2084 ``is_verified`` is True for Public Domain / Creative Commons artists.
2085 ``cc_license`` is the SPDX-style license string (e.g. "CC BY 4.0") or
2086 None for community users who retain all rights.
2087 """
2088
2089 user_id: str
2090 username: str
2091 display_name: str | None = None
2092 bio: str | None = None
2093 avatar_url: str | None = None
2094 location: str | None = None
2095 website_url: str | None = None
2096 social_url: str | None = None
2097 is_verified: bool = False
2098 cc_license: str | None = None
2099 pinned_repo_ids: list[str]
2100 repos: list[ProfileRepoSummary]
2101 session_credits: int
2102 created_at: datetime
2103 updated_at: datetime
2104
2105 # ── Cross-repo search models ───────────────────────────────────────────────────
2106
2107 class GlobalSearchCommitMatch(CamelModel):
2108 """A single commit that matched the search query in a cross-repo search.
2109
2110 Consumers display ``repo_id`` / ``repo_name`` as the group header, then
2111 render ``commit_id``, ``message``, and ``author`` as the match row.
2112 """
2113
2114 commit_id: str
2115 message: str
2116 author: str
2117 branch: str
2118 timestamp: datetime
2119 repo_id: str
2120 repo_name: str
2121 repo_owner: str
2122 repo_visibility: str
2123 # ── Webhook models ────────────────────────────────────────────────────────────
2124
2125 # Valid event types a subscriber may register for.
2126 WEBHOOK_EVENT_TYPES: frozenset[str] = frozenset(
2127 [
2128 "push",
2129 "proposal",
2130 "issue",
2131 "release",
2132 "branch",
2133 "tag",
2134 "session",
2135 "analysis",
2136 ]
2137 )
2138
2139 class WebhookCreate(CamelModel):
2140 """Body for POST /musehub/repos/{repo_id}/webhooks.
2141
2142 ``events`` must be a non-empty subset of the valid event-type strings
2143 (push, proposal, issue, release, branch, tag, session, analysis).
2144 ``secret`` is optional; when provided it is used to sign every delivery
2145 with HMAC-SHA256 in the ``X-MuseHub-Signature`` header.
2146
2147 ``url`` is validated against SSRF rules at parse time (scheme must be
2148 https; bare RFC-1918 / loopback IP literals are rejected immediately).
2149 Full DNS-resolution validation happens again in the delivery layer as
2150 defence in depth against DNS rebinding.
2151 """
2152
2153 url: str = Field(..., min_length=1, max_length=2048, description="HTTPS endpoint to deliver events to")
2154 events: list[str] = Field(..., min_length=1, description="Event types to subscribe to")
2155 secret: str = Field("", description="Optional HMAC-SHA256 signing secret")
2156
2157 @field_validator("url")
2158 @classmethod
2159 def _url_must_be_safe(cls, v: str) -> str:
2160 from musehub.security.ssrf import check_url_safe
2161 return check_url_safe(v)
2162
2163 class WebhookResponse(CamelModel):
2164 """Wire representation of a registered webhook subscription."""
2165
2166 webhook_id: str
2167 repo_id: str
2168 url: str
2169 events: list[str]
2170 active: bool
2171 created_at: datetime
2172
2173 @field_validator("webhook_id", "repo_id")
2174 @classmethod
2175 def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str:
2176 return _check_genesis_id(getattr(info, "field_name", "id"), v)
2177
2178 class WebhookListResponse(CamelModel):
2179 """Cursor-paginated list of webhook subscriptions for a repo.
2180
2181 Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page.
2182 A null ``nextCursor`` means this is the last page.
2183 """
2184
2185 webhooks: list[WebhookResponse]
2186 total: int = Field(0, ge=0, description="Total webhooks registered for this repo")
2187 next_cursor: str | None = Field(
2188 None,
2189 description=(
2190 "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. "
2191 "Null when this is the last page."
2192 ),
2193 )
2194
2195 class WebhookDeliveryResponse(CamelModel):
2196 """Wire representation of a single webhook delivery attempt.
2197
2198 ``payload`` is the JSON body that was (or will be) sent to the subscriber.
2199 It is stored verbatim so that operators can inspect the exact bytes delivered
2200 and so the redeliver endpoint can replay the original payload without guessing.
2201 """
2202
2203 delivery_id: str
2204 webhook_id: str
2205 event_type: str
2206 payload: str = Field("", description="JSON body sent to the subscriber URL")
2207 attempt: int
2208 success: bool
2209 response_status: int
2210 response_body: str
2211 delivered_at: datetime
2212
2213 class WebhookDeliveryListResponse(CamelModel):
2214 """Cursor-paginated list of delivery attempts for a webhook (newest first).
2215
2216 Pass ``nextCursor`` as ``?cursor=`` to retrieve older deliveries.
2217 A null ``nextCursor`` means this is the last page.
2218 """
2219
2220 deliveries: list[WebhookDeliveryResponse]
2221 total: int = Field(0, ge=0, description="Total delivery attempts for this webhook")
2222 next_cursor: str | None = Field(
2223 None,
2224 description=(
2225 "Opaque cursor for the next page (older deliveries). "
2226 "Pass verbatim as ?cursor= to advance. Null when this is the last page."
2227 ),
2228 )
2229
2230 class WebhookRedeliverResponse(CamelModel):
2231 """Confirmation that a delivery reattempt was executed.
2232
2233 ``success`` reflects the final outcome after all retry attempts.
2234 ``original_delivery_id`` links back to the delivery row that was replayed.
2235 """
2236
2237 original_delivery_id: str = Field(..., description="ID of the original delivery row that was retried")
2238 webhook_id: str = Field(..., description="Webhook the payload was redelivered to")
2239 event_type: str = Field(..., description="Event type of the redelivered payload")
2240 success: bool = Field(..., description="True when the redeliver attempt received a 2xx response")
2241 response_status: int = Field(..., description="HTTP status code from the final attempt (0 for network errors)")
2242 response_body: str = Field("", description="Response body snippet from the final attempt (≤512 chars)")
2243
2244 # ── Webhook event payload TypedDicts ─────────────────────────────────────────
2245 # These typed dicts are used as the payload argument to dispatch_event /
2246 # dispatch_event_background, replacing JSONObject at the service boundary.
2247
2248 class PushEventPayload(TypedDict):
2249 """Payload emitted when commits are pushed to a MuseHub repo.
2250
2251 Used with event_type="push".
2252 """
2253
2254 repoId: str
2255 branch: str
2256 headCommitId: str
2257 pushedBy: str
2258 commitCount: int
2259
2260 class IssueEventPayload(TypedDict):
2261 """Payload emitted when an issue is opened or closed.
2262
2263 ``action`` is either ``"opened"`` or ``"closed"``.
2264 Used with event_type="issue".
2265 """
2266
2267 repoId: str
2268 action: str
2269 issueId: str
2270 number: int
2271 title: str
2272 state: str
2273
2274 class ProposalEventPayload(TypedDict):
2275 """Payload emitted when a merge proposal is opened or merged.
2276
2277 ``action`` is either ``"opened"`` or ``"merged"``.
2278 ``mergeCommitId`` is only present on the "merged" action.
2279 Used with event_type="proposal".
2280 """
2281
2282 repoId: str
2283 action: str
2284 proposalId: str
2285 title: str
2286 fromBranch: str
2287 toBranch: str
2288 state: str
2289 mergeCommitId: NotRequired[str]
2290
2291 # Union of all typed webhook event payloads. The dispatcher accepts any of
2292 # these; callers pass the specific TypedDict for their event type.
2293 WebhookEventPayload = PushEventPayload | IssueEventPayload | ProposalEventPayload
2294
2295 # ── Context models ────────────────────────────────────────────────────────────
2296
2297 class MuseHubContextCommitInfo(CamelModel):
2298 """Minimal commit metadata included in a MuseHub context document."""
2299
2300 commit_id: str
2301 message: str
2302 author: str
2303 branch: str
2304 timestamp: datetime
2305
2306 class GlobalSearchRepoGroup(CamelModel):
2307 """All matching commits for a single repo, with repo-level metadata.
2308
2309 Results are grouped by repo so consumers can render a collapsible section
2310 per repo (name, owner) and paginate within each group.
2311
2312 ``repo_owner`` + ``repo_slug`` form the canonical /{owner}/{slug} UI URL.
2313 """
2314
2315 repo_id: str
2316 repo_name: str
2317 repo_owner: str
2318 repo_slug: str
2319 repo_visibility: str
2320 matches: list[GlobalSearchCommitMatch]
2321 total_matches: int
2322
2323 class GlobalSearchResult(CamelModel):
2324 """Top-level response for GET /search?q={query}.
2325
2326 ``groups`` contains one entry per public repo that had at least one
2327 matching commit. ``total_repos_searched`` is the count of repos searched,
2328 not just the repos with matches. Pass ``nextCursor`` as ``?cursor=`` to
2329 advance to the next page of repo-groups. A null ``nextCursor`` means this
2330 is the last page.
2331 """
2332
2333 query: str
2334 mode: str
2335 groups: list[GlobalSearchRepoGroup]
2336 total_repos_searched: int
2337 next_cursor: str | None = None
2338
2339 class MuseHubContextHistoryEntry(CamelModel):
2340 """A single ancestor commit in the evolutionary history of the composition.
2341
2342 History is built by walking parent_ids from the target commit.
2343 Entries are returned newest-first and limited to the last 5 ancestors.
2344 """
2345
2346 commit_id: str
2347 message: str
2348 author: str
2349 timestamp: datetime
2350 active_tracks: list[str]
2351
2352 class MuseHubContextMusicalState(CamelModel):
2353 """State at the target commit, derived from stored artifact paths.
2354
2355 ``active_tracks`` is populated from object paths in the repo.
2356 """
2357
2358 active_tracks: list[str]
2359
2360 class MuseHubContextResponse(CamelModel):
2361 """Human-readable and agent-consumable musical context document for a commit.
2362
2363 Returned by ``GET /api/repos/{repo_id}/context/{ref}``.
2364
2365 This is the MuseHub equivalent of ``MuseContextResult`` -- built from
2366 the remote repo's commit graph and stored objects rather than the local
2367 ``.muse`` filesystem. The structure deliberately mirrors ``MuseContextResult``
2368 so that agents consuming either source see the same schema.
2369
2370 Fields:
2371 repo_id: The hub repo identifier.
2372 current_branch: Branch name for the target commit.
2373 head_commit: Metadata for the resolved commit (ref).
2374 musical_state: Active tracks and any available musical dimensions.
2375 history: Up to 5 ancestor commits, newest-first.
2376 missing_elements: Dimensions that could not be determined from stored data.
2377 suggestions: Composer-facing hints about what to work on next.
2378 """
2379
2380 repo_id: str
2381 current_branch: str
2382 head_commit: MuseHubContextCommitInfo
2383 musical_state: MuseHubContextMusicalState
2384 history: list[MuseHubContextHistoryEntry]
2385 missing_elements: list[str]
2386 suggestions: StrDict
2387
2388 # ── In-repo search models ─────────────────────────────────────────────────────
2389
2390 class SearchCommitMatch(CamelModel):
2391 """A single commit returned by a search query.
2392
2393 Carries enough metadata to render a result row and launch an audio preview.
2394 The ``score`` field is populated by keyword/recall modes (0–1 overlap ratio);
2395 property and grep modes always return 1.0.
2396 """
2397
2398 commit_id: str
2399 branch: str
2400 message: str
2401 author: str
2402 timestamp: datetime
2403 score: float = Field(1.0, ge=0.0, le=1.0, description="Match score (0–1); always 1.0 for exact-match modes")
2404 match_source: str = Field("message", description="Where the match was found: 'message', 'branch', or 'property'")
2405
2406 class SearchResponse(CamelModel):
2407 """Response envelope for all four in-repo search modes.
2408
2409 ``mode`` echoes back the requested search mode so clients can render
2410 mode-appropriate headers. ``total_scanned`` is the number of commits
2411 examined before limit was applied; useful for indicating search depth.
2412 """
2413
2414 mode: str
2415 query: str
2416 matches: list[SearchCommitMatch]
2417 total_scanned: int
2418 limit: int
2419
2420 # ── DAG graph models ───────────────────────────────────────────────────────────
2421
2422 class DagNode(CamelModel):
2423 """A single commit node in the repo's directed acyclic graph.
2424
2425 Designed for consumption by interactive graph renderers. The ``is_head``
2426 flag marks the current HEAD commit across all branches. ``branch_labels``
2427 and ``tag_labels`` list all ref names pointing at this commit.
2428
2429 Muse-specific semantic fields (absent in Git) allow renderers to encode
2430 the *type* and *significance* of each commit visually:
2431
2432 - ``commit_type``: conventional-commit prefix (feat, fix, refactor, …)
2433 - ``sem_ver_bump``: version significance (major, minor, patch, none)
2434 - ``is_breaking``: true when the commit contains breaking changes
2435 - ``is_agent``: true when committed by an AI agent rather than a human
2436 - ``sym_added`` / ``sym_removed``: count of AST symbol operations
2437 """
2438
2439 commit_id: str
2440 message: str
2441 author: str
2442 timestamp: datetime
2443 branch: str
2444 parent_ids: list[str]
2445 is_head: bool = False
2446 branch_labels: list[str] = Field(default_factory=list)
2447 tag_labels: list[str] = Field(default_factory=list)
2448 # Muse semantic enrichment
2449 commit_type: str = ""
2450 sem_ver_bump: str = "none"
2451 is_breaking: bool = False
2452 is_agent: bool = False
2453 sym_added: int = 0
2454 sym_removed: int = 0
2455
2456 class DagEdge(CamelModel):
2457 """A directed edge in the commit DAG.
2458
2459 ``source`` is the child commit (the one that has the parent).
2460 ``target`` is the parent commit. This follows standard graph convention:
2461 edge flows from child → parent (newest to oldest).
2462 """
2463
2464 source: str
2465 target: str
2466
2467 class DagGraphResponse(CamelModel):
2468 """Topologically sorted commit graph for a MuseHub repo.
2469
2470 ``nodes`` are ordered from oldest ancestor to newest commit (Kahn's
2471 algorithm). ``edges`` enumerate every parent→child relationship.
2472 Consumers can render this directly as a directed acyclic graph without
2473 further processing.
2474
2475 Agent use case: an AI music agent can use this to identify which branches
2476 diverged from a common ancestor, find merge points, and reason about the
2477 project's compositional history.
2478 """
2479
2480 nodes: list[DagNode]
2481 edges: list[DagEdge]
2482 head_commit_id: str | None = None
2483
2484 # ── Session models ─────────────────────────────────────────────────────────────
2485
2486 class SessionCreate(CamelModel):
2487 """Body for POST /musehub/repos/{repo_id}/sessions.
2488
2489 Sent by the CLI on ``muse session start`` to register a new session.
2490 ``started_at`` defaults to the server's current time when absent.
2491 """
2492
2493 started_at: datetime | None = Field(default=None, description="Session start time; defaults to server time when absent")
2494 participants: list[str] = Field(
2495 default_factory=list,
2496 description="Participant identifiers or display names",
2497 examples=[["miles_davis", "john_coltrane"]],
2498 )
2499 intent: str = Field(
2500 "",
2501 description="Free-text creative goal for this session",
2502 examples=["Finish the bossa nova bridge — add percussion and finalize the chord changes"],
2503 )
2504 location: str = Field(
2505 "",
2506 max_length=255,
2507 description="Studio or location label",
2508 examples=["Blue Note Studio, NYC"],
2509 )
2510 is_active: bool = Field(True, description="True if the session is currently live")
2511
2512 class SessionStop(CamelModel):
2513 """Body for POST /musehub/repos/{repo_id}/sessions/{session_id}/stop.
2514
2515 Sent by the CLI on ``muse session stop`` to mark a session as ended.
2516 """
2517
2518 ended_at: datetime | None = None
2519
2520 class SessionResponse(CamelModel):
2521 """Wire representation of a single recording session.
2522
2523 ``duration_seconds`` is derived from ``started_at`` and ``ended_at``;
2524 None when the session is still active (``ended_at`` is null).
2525 ``is_active`` is True while the session is open -- used by the Hub UI to
2526 render a live indicator.
2527 ``commits`` is the ordered list of Muse commit IDs associated with this session;
2528 the UI uses ``len(commits)`` as the commit count badge and the graph page
2529 uses it to apply session markers on commit nodes.
2530 ``notes`` contains closing markdown notes authored after the session ends.
2531 """
2532
2533 session_id: str
2534 started_at: datetime
2535 ended_at: datetime | None = None
2536 duration_seconds: float | None = None
2537 participants: list[str]
2538 commits: list[str] = Field(default_factory=list, description="Muse commit IDs recorded during this session")
2539 notes: str = Field("", description="Closing notes for the session (markdown)")
2540 intent: str
2541 location: str
2542 is_active: bool
2543 created_at: datetime
2544
2545 @field_validator("session_id")
2546 @classmethod
2547 def _check_session_id(cls, v: str) -> str:
2548 return _check_genesis_id("session_id", v)
2549
2550 class SessionListResponse(CamelModel):
2551 """Cursor-paginated list of sessions for a repo (newest first).
2552
2553 ``next_cursor`` is ``None`` on the last page. Pass it as ``?cursor=``
2554 on the next request to retrieve the following page.
2555 """
2556
2557 sessions: list[SessionResponse]
2558 total: int
2559 next_cursor: str | None = None
2560
2561 class ActivityEventResponse(CamelModel):
2562 """Wire representation of a single repo-level activity event.
2563
2564 ``event_type`` is one of:
2565 "commit_pushed" | "proposal_opened" | "proposal_merged" | "proposal_closed" |
2566 "issue_opened" | "issue_closed" | "branch_created" | "branch_deleted" |
2567 "tag_pushed" | "session_started" | "session_ended"
2568
2569 ``metadata`` carries event-specific structured data for deep-link rendering
2570 (e.g. ``{"sha": "abc123", "message": "Add groove baseline"}`` for commit_pushed).
2571 """
2572
2573 event_id: str
2574 repo_id: str
2575 event_type: str
2576 actor: str
2577 description: str
2578 metadata: _JsonMeta = Field(default_factory=dict)
2579 created_at: datetime
2580
2581 # ── User public activity feed models ─────────────────────────────────────────
2582
2583 class UserActivityEventItem(CamelModel):
2584 """A single event in a user's public activity feed.
2585
2586 Uses the public API type vocabulary (push, proposal, issue, release)
2587 rather than the internal DB event_type vocabulary (commit_pushed, proposal_opened, …).
2588 ``repo`` is the human-readable "{owner}/{slug}" identifier for deep-linking
2589 to the repo page without exposing internal repo_id sha256 genesis hashes.
2590 ``payload`` carries event-specific structured data (e.g. branch name and
2591 head commit message for push events, proposal number and title for proposal events).
2592 """
2593
2594 id: str = Field(..., description="Internal ID for this event")
2595 type: str = Field(
2596 ...,
2597 description="Public event type: push | proposal | issue | release",
2598 )
2599 actor: str = Field(..., description="Username who triggered the event")
2600 repo: str = Field(..., description="Repo identifier as '{owner}/{slug}'")
2601 payload: _JsonMeta = Field(
2602 default_factory=dict,
2603 description="Event-specific structured data for deep-link rendering",
2604 )
2605 created_at: datetime = Field(..., description="Event creation timestamp (ISO-8601 UTC)")
2606
2607 class UserActivityFeedResponse(CamelModel):
2608 """Cursor-paginated public activity feed for a MuseHub user (newest-first).
2609
2610 ``events`` contains up to ``limit`` events for the given user, filtered to
2611 public repos only (or all repos when the caller is the profile owner).
2612 ``next_cursor`` is the event ID to pass as ``before_id`` in the next
2613 request to fetch the subsequent page; None when there are no more events.
2614 ``type_filter`` echoes back the ``type`` query param, or None when all types
2615 are shown.
2616
2617 Agent use case: stream this feed to build a real-time view of what a
2618 collaborator has been working on across all their public repos.
2619 """
2620
2621 events: list[UserActivityEventItem]
2622 next_cursor: str | None = Field(
2623 None,
2624 description="Pass as before_id to fetch the next page; None on the last page",
2625 )
2626 type_filter: str | None = Field(
2627 None,
2628 description="Active type filter value, or None when all types are shown",
2629 )
2630
2631 # ── Tree browser models ───────────────────────────────────────────────────────
2632
2633 class TreeEntryResponse(CamelModel):
2634 """A single entry (file or directory) in the Muse tree browser.
2635
2636 Returned by GET /musehub/repos/{repo_id}/tree/{ref} and
2637 GET /musehub/repos/{repo_id}/tree/{ref}/{path}.
2638
2639 Consumers should use ``type`` to render the appropriate icon:
2640 - "dir" → folder icon, clickable to navigate deeper
2641 - "file" → file-type icon based on ``name`` extension
2642 (.mid → piano, .mp3/.wav → waveform, .json → braces, .webp/.png → photo)
2643
2644 ``size_bytes`` is None for directories (size is the sum of its contents,
2645 which the server does not compute at list time).
2646 """
2647
2648 type: str = Field(..., description="'file' or 'dir'")
2649 name: str = Field(..., description="Entry filename or directory name")
2650 path: str = Field(..., description="Full relative path from repo root, e.g. 'tracks/bass.mid'")
2651 size_bytes: int | None = Field(None, description="File size in bytes; None for directories")
2652 object_id: str | None = Field(None, description="Content-addressed object ID; None for directories and legacy entries")
2653
2654 class TreeListResponse(CamelModel):
2655 """Directory listing for the Muse tree browser.
2656
2657 Returned by GET /musehub/repos/{repo_id}/tree/{ref} and
2658 GET /musehub/repos/{repo_id}/tree/{ref}/{path}.
2659
2660 Directories are listed before files within the same level. Within each
2661 group, entries are sorted alphabetically by name.
2662
2663 Agent use case: use this to enumerate files at a known ref without
2664 downloading any content. Combine with ``/objects/{object_id}/content``
2665 to read individual files.
2666 """
2667
2668 owner: str
2669 repo_slug: str
2670 ref: str = Field(..., description="The branch name or commit SHA used to resolve the tree")
2671 dir_path: str = Field(
2672 ..., description="Current directory path being listed; empty string for repo root"
2673 )
2674 entries: list[TreeEntryResponse] = Field(default_factory=list)
2675
2676 # ── Groove Check models ───────────────────────────────────────────────────────
2677
2678 class GrooveCommitEntry(CamelModel):
2679 """Per-commit groove metrics within a groove-check analysis window.
2680
2681 groove_score — average note-onset deviation from the quantization grid,
2682 measured in beats (lower = tighter to the grid).
2683 drift_delta — absolute change in groove_score relative to the prior
2684 commit. The oldest commit in the window always has 0.0.
2685 status — OK / WARN / FAIL classification against the threshold.
2686 """
2687
2688 commit: str = Field(..., description="Short commit reference (8 hex chars)")
2689 groove_score: float = Field(
2690 ..., description="Average onset deviation from quantization grid, in beats"
2691 )
2692 drift_delta: float = Field(
2693 ..., description="Absolute change in groove_score vs prior commit"
2694 )
2695 status: str = Field(..., description="OK / WARN / FAIL classification")
2696 track: str = Field(..., description="Track scope analysed, or 'all'")
2697 section: str = Field(..., description="Section scope analysed, or 'all'")
2698 midi_files: int = Field(..., description="Number of MIDI snapshots analysed")
2699
2700 class BlobMetaResponse(CamelModel):
2701 """Wire representation of a single file (blob) in the Muse tree browser.
2702
2703 Returned by GET /musehub/repos/{repo_id}/blob/{ref}/{path}.
2704 Consumers use ``file_type`` to choose the appropriate rendering mode
2705 (piano roll for MIDI, audio player for MP3/WAV, inline img for images,
2706 syntax-highlighted text for JSON/XML, hex dump for unknown binaries).
2707 ``content_text`` is populated only for text files up to 256 KB; binary
2708 files should use ``raw_url`` to stream content.
2709 """
2710
2711 object_id: str = Field(..., description="Content-addressed ID, e.g. 'sha256:abc123...'")
2712 path: str = Field(..., description="Relative path from repo root, e.g. 'tracks/bass.mid'")
2713 filename: str = Field(..., description="Basename of the file, e.g. 'bass.mid'")
2714 size_bytes: int = Field(..., description="File size in bytes")
2715 sha: str = Field(..., description="Content-addressed SHA identifier")
2716 created_at: datetime = Field(..., description="Timestamp when this object was pushed")
2717 raw_url: str = Field(..., description="URL to download the raw file bytes")
2718 file_type: str = Field(
2719 ...,
2720 description="Rendering hint: 'midi' | 'audio' | 'json' | 'image' | 'xml' | 'other'",
2721 )
2722 content_text: str | None = Field(
2723 None,
2724 description="UTF-8 content for JSON/XML files up to 256 KB; None for binary or oversized files",
2725 )
2726
2727 class GrooveCheckResponse(CamelModel):
2728 """Rhythmic consistency dashboard data for a commit range in a MuseHub repo.
2729
2730 Aggregates timing deviation, swing ratio, and quantization tightness
2731 metrics derived from MIDI snapshots across a window of commits. The
2732 ``entries`` list is ordered oldest-first so consumers can plot groove
2733 evolution over time.
2734 """
2735
2736 commit_range: str = Field(..., description="Commit range string that was analysed")
2737 threshold: float = Field(
2738 ..., description="Drift threshold in beats used for WARN/FAIL classification"
2739 )
2740 total_commits: int = Field(..., description="Total commits in the analysis window")
2741 flagged_commits: int = Field(
2742 ..., description="Number of commits with WARN or FAIL status"
2743 )
2744 worst_commit: str = Field(
2745 ..., description="Commit ref with the highest drift_delta, or empty string"
2746 )
2747 entries: list[GrooveCommitEntry] = Field(
2748 default_factory=list,
2749 description="Per-commit metrics, oldest-first",
2750 )
2751
2752 # ── Compare view models ────────────────────────────────────────────────────────
2753
2754 class EmotionDiff(CamelModel):
2755 """Emotion vector delta between base and head refs.
2756
2757 Absolute values (base*/head*) are in [0.0, 1.0].
2758 Delta values (*Delta) are head minus base, in [-1.0, 1.0].
2759 """
2760
2761 base_energy: float = Field(..., ge=0.0, le=1.0, description="Energy score for base ref")
2762 head_energy: float = Field(..., ge=0.0, le=1.0, description="Energy score for head ref")
2763 base_valence: float = Field(..., ge=0.0, le=1.0, description="Valence score for base ref")
2764 head_valence: float = Field(..., ge=0.0, le=1.0, description="Valence score for head ref")
2765 energy_delta: float = Field(..., ge=-1.0, le=1.0, description="head_energy - base_energy")
2766 valence_delta: float = Field(..., ge=-1.0, le=1.0, description="head_valence - base_valence")
2767 tension_delta: float = Field(..., ge=-1.0, le=1.0, description="Change in harmonic tension")
2768 darkness_delta: float = Field(..., ge=-1.0, le=1.0, description="Change in modal darkness")
2769
2770 class CompareResponse(CamelModel):
2771 """Multi-dimensional comparison between two refs in a MuseHub repo.
2772
2773 Returned by ``GET /musehub/repos/{repo_id}/compare?base=X&head=Y``.
2774 Combines divergence scores and unique commits into a single payload that
2775 powers the compare page UI.
2776
2777 The ``commits`` list contains only commits reachable from ``head`` but not
2778 from ``base`` (i.e. commits unique to head), newest first.
2779 """
2780
2781 repo_id: str = Field(..., description="Repository identifier")
2782 base_ref: str = Field(..., description="Base ref (branch name, tag, or commit SHA)")
2783 head_ref: str = Field(..., description="Head ref (branch name, tag, or commit SHA)")
2784 common_ancestor: str | None = Field(
2785 default=None,
2786 description="Most recent common ancestor commit ID, or null if histories are disjoint",
2787 )
2788 dimensions: list[DivergenceDimensionResponse] = Field(
2789 ..., description="Per-dimension divergence scores"
2790 )
2791 overall_score: float = Field(
2792 ..., description="Mean of all dimension scores in [0.0, 1.0]"
2793 )
2794 commits: list[CommitResponse] = Field(
2795 ..., description="Commits in head not in base (newest first)"
2796 )
2797 create_proposal_url: str = Field(
2798 ..., description="URL to create a merge proposal from this comparison"
2799 )
2800 emotion_diff: EmotionDiff = Field(
2801 ..., description="Emotion vector delta between base and head refs"
2802 )
2803
2804 # ── Fork models ────────────────────────────────────────────────────────────
2805
2806 class ForkRepoRequest(CamelModel):
2807 """Request body for ``POST /api/repos/{repo_id}/fork``.
2808
2809 All fields are optional — omitting ``name`` copies the source repo's name.
2810 The caller's authenticated handle is always used as the owner;
2811 the request body cannot override it.
2812 """
2813
2814 name: str | None = Field(
2815 None,
2816 description=(
2817 "Name for the new fork repo. Defaults to the source repo's name. "
2818 "A unique slug is auto-generated; use this to disambiguate when you "
2819 "already own a repo with the same slug as the source."
2820 ),
2821 )
2822 description: str | None = Field(
2823 None,
2824 description=(
2825 "Description for the fork. Defaults to the source repo's description "
2826 "prefixed with 'Fork of {owner}/{slug}: '."
2827 ),
2828 )
2829 visibility: Literal["public", "private"] | None = Field(
2830 None,
2831 description="Visibility for the fork repo. Defaults to 'public'.",
2832 )
2833
2834 class UserForkedRepoEntry(CamelModel):
2835 """A single forked repo entry shown on a user's profile Forked tab.
2836
2837 Combines the fork repo's full metadata with source attribution so the
2838 profile page can render "forked from {source_owner}/{source_slug}" under
2839 each card.
2840 """
2841
2842 fork_id: str = Field(..., description="Genesis-addressed fork relationship ID")
2843 fork_repo: RepoResponse = Field(..., description="Full metadata of the forked (child) repo")
2844 source_owner: str = Field(..., description="Owner username of the original source repo")
2845 source_slug: str = Field(..., description="Slug of the original source repo")
2846 forked_at: datetime = Field(..., description="Timestamp when the fork was created (ISO-8601 UTC)")
2847
2848 @field_validator("fork_id")
2849 @classmethod
2850 def _check_fork_id(cls, v: str) -> str:
2851 return _check_genesis_id("fork_id", v)
2852
2853 class UserForksResponse(CamelModel):
2854 """Paginated list of repos forked by a user.
2855
2856 Returned by ``GET /api/users/{username}/forks``.
2857 """
2858
2859 forks: list[UserForkedRepoEntry] = Field(..., description="Repos forked by this user")
2860 total: int = Field(..., description="Total number of forked repos")
2861
2862 class ForkNetworkNode(CamelModel):
2863 """A single node in the fork network tree.
2864
2865 Represents one repo (root or fork) with its owner/slug identity,
2866 the number of commits it has diverged from its immediate parent,
2867 and its own children in the tree.
2868
2869 Used by ``GET /musehub/ui/{owner}/{repo_slug}/forks`` (JSON path)
2870 to surface the full network graph for programmatic traversal.
2871 """
2872
2873 owner: str = Field(..., description="Owner username of this repo")
2874 repo_slug: str = Field(..., description="Slug of this repo")
2875 repo_id: str = Field(..., description="sha256 genesis ID of this repo")
2876 divergence_commits: int = Field(
2877 ...,
2878 description="Commits this fork has ahead of its immediate parent (0 for root)",
2879 )
2880 forked_by: str = Field(
2881 ..., description="User ID who created the fork (empty string for root repo)"
2882 )
2883 forked_at: datetime | None = Field(
2884 None, description="Timestamp when the fork was created (None for root repo)"
2885 )
2886 children: list["ForkNetworkNode"] = Field(
2887 default_factory=list,
2888 description="Direct forks of this repo, each recursively carrying their own children",
2889 )
2890
2891 class ForkNetworkResponse(CamelModel):
2892 """Fork network graph for a repo — root with recursive children.
2893
2894 Returned by ``GET /musehub/ui/{owner}/{repo_slug}/forks?format=json``.
2895
2896 The ``root`` node represents the canonical upstream repo. Each
2897 ``ForkNetworkNode`` in ``root.children`` is a direct fork; their
2898 own ``children`` lists contain second-level forks, and so on.
2899
2900 ``total_forks`` is the flat count of all fork nodes in the tree
2901 (excluding the root), so callers can display "N forks" without
2902 walking the tree.
2903
2904 Agent use case: determine how many downstream forks exist, identify
2905 the most-diverged fork before proposing a merge-back proposal, or decide
2906 which fork to merge into the root.
2907 """
2908
2909 root: ForkNetworkNode = Field(..., description="Root repo (the upstream source)")
2910 total_forks: int = Field(..., description="Total number of fork nodes in the network")
2911
2912 # Resolve forward reference in self-referential ForkNetworkNode.children
2913 ForkNetworkNode.model_rebuild()
2914
2915 # ── Render pipeline ────────────────────────────────────────────────────────
2916
2917 class RepoSettingsResponse(CamelModel):
2918 """Mutable settings for a MuseHub repo.
2919
2920 Returned by ``GET /api/repos/{repo_id}/settings``.
2921
2922 Fields map to GitHub-style repo settings. ``name``, ``description``,
2923 ``visibility``, and ``topics`` are stored in dedicated repo columns;
2924 all remaining flags are stored in the ``settings`` JSON blob.
2925
2926 Agent use case: read before updating project metadata, toggling features,
2927 or configuring merge strategy for a repo's proposal workflow.
2928 """
2929
2930 name: str = Field(..., description="Human-readable repo name")
2931 description: str = Field("", description="Short description shown on the explore page")
2932 visibility: str = Field(..., description="'public' or 'private'")
2933 default_branch: str = Field("main", description="Default branch name (used for clone and proposals)")
2934 has_issues: bool = Field(True, description="Whether the issues tracker is enabled")
2935 has_projects: bool = Field(False, description="Whether the projects board is enabled")
2936 has_wiki: bool = Field(False, description="Whether the wiki is enabled")
2937 topics: list[str] = Field(default_factory=list, description="Free-form topic tags")
2938 license: str | None = Field(None, description="SPDX license identifier or display name, e.g. 'CC BY 4.0'")
2939 homepage_url: str | None = Field(None, description="Project homepage URL")
2940 allow_merge_commit: bool = Field(True, description="Allow merge commits on proposals")
2941 allow_squash_merge: bool = Field(True, description="Allow squash merges on proposals")
2942 allow_rebase_merge: bool = Field(False, description="Allow rebase merges on proposals")
2943 delete_branch_on_merge: bool = Field(True, description="Auto-delete head branch after proposal merge")
2944 domain_id: str | None = Field(None, description="ID of the Muse domain plugin for this repo")
2945
2946 class RepoSettingsPatch(CamelModel):
2947 """Partial update body for ``PATCH /api/repos/{repo_id}/settings``.
2948
2949 All fields are optional — only provided fields are updated.
2950 ``visibility`` must be ``'public'`` or ``'private'`` when supplied.
2951 Caller must hold owner or admin collaborator permission; otherwise 403 is returned.
2952
2953 Agent use case: update repo visibility, merge strategy, or homepage URL
2954 without knowing the full settings object.
2955 """
2956
2957 name: str | None = Field(None, description="New repo name")
2958 description: str | None = Field(None, description="New description")
2959 visibility: str | None = Field(
2960 None,
2961 pattern="^(public|private)$",
2962 description="'public' or 'private'",
2963 )
2964 default_branch: str | None = Field(None, description="New default branch name")
2965 has_issues: bool | None = Field(None, description="Enable/disable issues tracker")
2966 has_projects: bool | None = Field(None, description="Enable/disable projects board")
2967 has_wiki: bool | None = Field(None, description="Enable/disable wiki")
2968 topics: list[str] | None = Field(None, description="Replace topic tags (full list)")
2969 license: str | None = Field(None, description="SPDX license identifier or display name")
2970 homepage_url: str | None = Field(None, description="Project homepage URL")
2971 allow_merge_commit: bool | None = Field(None, description="Allow merge commits on proposals")
2972 allow_squash_merge: bool | None = Field(None, description="Allow squash merges on proposals")
2973 allow_rebase_merge: bool | None = Field(None, description="Allow rebase merges on proposals")
2974 delete_branch_on_merge: bool | None = Field(None, description="Auto-delete head branch after proposal merge")
2975 domain_id: str | None = Field(None, description="ID of the Muse domain plugin for this repo")
2976
2977 # ── Symbol-level blame models ────────────────────────────────────────────────
2978
2979 class SymbolBlameEntry(CamelModel):
2980 """One blame annotation attributing a symbol to the commit that last modified it.
2981
2982 Derived from the symbol history index built by ``build_symbol_index``.
2983 Each entry represents a named symbol (function, class, variable) in the
2984 target file, attributed to the most recent commit that introduced or
2985 modified it.
2986 """
2987
2988 symbol_address: str = Field(..., description="Full address e.g. 'path/to/file.py::MyFunc'")
2989 symbol_name: str = Field(..., description="Short symbol name, e.g. 'MyFunc'")
2990 commit_id: str = Field(..., description="Commit that last modified this symbol")
2991 commit_message: str = Field(..., description="Message of that commit")
2992 author: str = Field(..., description="Author of that commit")
2993 timestamp: datetime = Field(..., description="When that commit was made")
2994 op: str = Field(..., description="Last operation: 'add' or 'modify'")
2995 change_count: int = Field(default=1, description="Total times this symbol has changed")
2996 # Intel signals — populated by _build_real_symbol_blame when intel is available
2997 is_hotspot: bool = Field(default=False, description="Change count exceeds hotspot threshold")
2998 is_dead: bool = Field(default=False, description="Untouched for >= 90 days")
2999 is_blast_risk: bool = Field(default=False, description="High co-change count with other symbols")
3000 blast_co_symbols: list[str] = Field(default_factory=list, description="Top symbols that co-change with this one")
3001
3002 class SymbolBlameResponse(CamelModel):
3003 """Response envelope for symbol-level blame."""
3004
3005 entries: list[SymbolBlameEntry] = Field(default_factory=list)
3006 total_entries: int = Field(default=0)
3007 path: str = Field(default="")
3008
3009 # ── Collaborator access-check model ─────────────────────────────────────────
3010
3011 class CollaboratorAccessResponse(CamelModel):
3012 """Response for the collaborator access-check endpoint.
3013
3014 Returns the effective permission level for a given username on a repo.
3015 The owner's effective permission is always ``"owner"``. Non-collaborators
3016 are reported as 404 rather than returning a ``"none"`` permission value,
3017 so callers can distinguish a known absence (404) from a positive result.
3018
3019 ``accepted_at`` is ``null`` for the repo owner (ownership is immediate)
3020 and for collaborators whose invitation is still pending acceptance.
3021 """
3022
3023 username: str = Field(..., description="User identifier supplied in the request path")
3024 permission: str = Field(
3025 ...,
3026 description="Effective permission level: 'read' | 'write' | 'admin' | 'owner'",
3027 )
3028 accepted_at: datetime | None = Field(
3029 None,
3030 description="UTC timestamp when the collaborator accepted the invitation; null for owners",
3031 )
3032
3033 class ChangelogEntryResponse(CamelModel):
3034 """A single changelog entry auto-generated from commit metadata.
3035
3036 Entries are produced by walking the commit graph between releases and
3037 extracting ``sem_ver_bump`` and ``breaking_changes`` from each commit's
3038 structured metadata. No conventional-commit parsing is required.
3039 """
3040
3041 commit_id: str
3042 message: str
3043 sem_ver_bump: str = ""
3044 breaking_changes: list[str] = Field(default_factory=list)
3045 author: str = ""
3046 timestamp: str = ""
3047
3048 class SemanticReleaseReportResponse(CamelModel):
3049 """Semantic analysis of a release, computed by the Muse CLI at push time.
3050
3051 MuseHub stores this blob verbatim and renders it in the release detail page.
3052 All list fields default to ``[]`` and int fields to ``0`` so that a missing
3053 or partial report still deserialises cleanly.
3054 """
3055
3056 # Snapshot composition
3057 languages: list[LanguageStatResponse] = Field(default_factory=list)
3058 total_files: int = 0
3059 semantic_files: int = 0
3060 total_symbols: int = 0
3061 symbols_by_kind: list[SymbolKindCountResponse] = Field(default_factory=list)
3062
3063 # Delta — what changed in this release vs previous
3064 files_changed: int = 0
3065 api_added: list[ApiChangeSummaryResponse] = Field(default_factory=list)
3066 api_removed: list[ApiChangeSummaryResponse] = Field(default_factory=list)
3067 api_modified: list[ApiChangeSummaryResponse] = Field(default_factory=list)
3068 file_hotspots: list[FileHotspotResponse] = Field(default_factory=list)
3069 refactor_events: list[RefactorEventResponse] = Field(default_factory=list)
3070
3071 # Provenance
3072 breaking_changes: list[str] = Field(default_factory=list)
3073 human_commits: int = 0
3074 agent_commits: int = 0
3075 unique_agents: list[str] = Field(default_factory=list)
3076 unique_models: list[str] = Field(default_factory=list)
3077 reviewers: list[str] = Field(default_factory=list)
3078
3079 class LanguageStatResponse(CamelModel):
3080 """File and symbol counts for a single programming language."""
3081
3082 language: str
3083 files: int = 0
3084 symbols: int = 0
3085
3086 class SymbolKindCountResponse(CamelModel):
3087 """Count of symbols of a specific kind in the release snapshot."""
3088
3089 kind: str
3090 count: int = 0
3091
3092 class ApiChangeSummaryResponse(CamelModel):
3093 """A public-API symbol that was added, removed, or modified."""
3094
3095 address: str
3096 language: str = ""
3097 kind: str = ""
3098 change: str = "" # "added" | "removed" | "modified"
3099
3100 class FileHotspotResponse(CamelModel):
3101 """A file and how many times it was touched across the release's commits."""
3102
3103 file_path: str
3104 change_count: int = 0
3105 language: str = ""
3106
3107 class RefactorEventResponse(CamelModel):
3108 """A single structural refactoring event detected in the release."""
3109
3110 kind: str = "" # "rename" | "move" | "add" | "delete" | "patch"
3111 address: str = ""
3112 detail: str = ""
3113 commit_id: str = ""
3114
3115 class WireTagInput(CamelModel):
3116 """A single lightweight tag pushed from a Muse CLI client.
3117
3118 Wire tags annotate commits with semantic labels (e.g. ``emotion:joyful``,
3119 ``section:verse``) that are separate from version releases. The server
3120 upserts them — pushing the same tag twice is a no-op.
3121 """
3122
3123 tag_id: str = Field(..., description="Genesis-addressed ID for the tag")
3124 commit_id: str = Field(..., description="Commit this tag points to")
3125 tag: str = Field(..., min_length=1, max_length=500, description="Tag label, e.g. 'emotion:joyful'")
3126 created_at: str = Field("", description="ISO-8601 creation timestamp from the client")
3127
3128 @field_validator("tag_id", "commit_id")
3129 @classmethod
3130 def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str:
3131 return _check_genesis_id(getattr(info, "field_name", "id"), v)
3132
3133 # ---------------------------------------------------------------------------
3134 # Agent Fleet — agents deployed by a human identity
3135 # ---------------------------------------------------------------------------
3136
3137 class AgentCardEntry(CamelModel):
3138 """A single agent that has committed to repos owned by a human identity.
3139
3140 Aggregated from ``agent_id`` / ``model_id`` columns across all commits in
3141 repos owned by the queried handle. ``model_label`` is a human-readable
3142 short name derived from ``model_id``.
3143 """
3144
3145 agent_id: str
3146 model_id: str | None = None
3147 model_label: str
3148 commit_count: int
3149 repo_count: int
3150 last_seen: datetime
3151
3152 class AgentFleetResponse(CamelModel):
3153 """All agents deployed by a given handle, sorted by commit volume."""
3154
3155 handle: str
3156 agents: list[AgentCardEntry]
3157 total: int
3158
3159 # ---------------------------------------------------------------------------
3160 # Attestation schemas
3161 # ---------------------------------------------------------------------------
3162
3163 class AttestationRequest(CamelModel):
3164 """Body for POST /api/profiles/{handle}/attestations.
3165
3166 The caller provides the pre-computed Ed25519 signature over the canonical
3167 ATTEST message so the server can verify without holding any private key.
3168
3169 Canonical message (UTF-8, newline separated) for identity scope:
3170 ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}
3171
3172 For repo/commit scope, scope_ref is appended:
3173 ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}\\n{scope_ref}
3174
3175 ``scope`` must be one of: ``identity`` | ``repo`` | ``commit``.
3176 ``scope_ref`` is required when scope is ``repo`` or ``commit``.
3177 ``commit_id`` (``sha256:...``) is required when scope is ``commit``.
3178 ``expires_at`` is optional; expired attestations are excluded from default
3179 queries but remain in the DB for audit purposes.
3180 """
3181
3182 attester: str = Field(..., description="Handle of the identity issuing the attestation")
3183 subject: str = Field(..., description="Handle or slug being attested (handle for identity; handle/repo for repo/commit scope)")
3184 claim: str = Field(..., description="JSON claim payload; top-level 'type' key must be a registered claim type")
3185 signature: str = Field(..., description="Ed25519 signature over canonical ATTEST message; 'ed25519:<base64url>'")
3186 attester_public_key: str = Field(..., description="Attester public key at time of issuance; 'ed25519:<base64url>'")
3187 issued_at: datetime = Field(..., description="ISO-8601 timestamp of issuance (included in canonical message)")
3188 scope: str = Field("identity", description="Attestation scope: 'identity' | 'repo' | 'commit'")
3189 scope_ref: str | None = Field(None, description="Full subject reference; required for repo/commit scope (e.g. 'gabriel/musehub@sha256:...')")
3190 repo_id: str | None = Field(None, description="Repo slug for repo/commit-scoped attestations")
3191 commit_id: str | None = Field(None, description="Content-addressed commit ID ('sha256:...') for commit-scoped attestations")
3192 expires_at: datetime | None = Field(None, description="Optional expiry; excluded from live queries after this timestamp")
3193
3194 @model_validator(mode="after")
3195 def _validate_scope_fields(self) -> "AttestationRequest":
3196 """Enforce scope_ref is present for repo/commit scopes."""
3197 if self.scope in ("repo", "commit") and not self.scope_ref:
3198 raise ValueError(f"scope_ref is required when scope is '{self.scope}'")
3199 if self.scope == "commit" and not self.commit_id:
3200 raise ValueError("commit_id is required when scope is 'commit'")
3201 return self
3202
3203
3204 class AttestationResponse(CamelModel):
3205 """A single attestation record as returned by the hub."""
3206
3207 attestation_id: str
3208 attester: str
3209 subject: str
3210 claim: str
3211 signature: str
3212 attester_public_key: str
3213 issued_at: datetime
3214 revoked_at: datetime | None = None
3215 scope: str = "identity"
3216 scope_ref: str | None = None
3217 repo_id: str | None = None
3218 commit_id: str | None = None
3219 expires_at: datetime | None = None
3220
3221
3222 class AttestationListResponse(CamelModel):
3223 """Paginated list of attestations for a subject, attester, repo, or commit."""
3224
3225 subject: str
3226 attestations: list[AttestationResponse]
3227 total: int
3228
3229
3230 class ClaimTypeRecord(CamelModel):
3231 """A single entry from the claim type registry."""
3232
3233 type_key: str
3234 category: str
3235 label: str
3236 description: str
3237 valid_scopes: list[str]
3238 deprecated_at: datetime | None = None
3239
3240 def __getitem__(self, key: str) -> object:
3241 return getattr(self, key)
3242
3243
3244 class ClaimTypeListResponse(CamelModel):
3245 """All registered claim types."""
3246
3247 claim_types: list[ClaimTypeRecord]
3248 total: int
3249
3250 # ---------------------------------------------------------------------------
3251 # MPay claim schemas
3252 # ---------------------------------------------------------------------------
3253
3254 class MPayClaimRequest(CamelModel):
3255 """Body for POST /api/profiles/{handle}/mpay-claims.
3256
3257 The sender provides their signature over the canonical MPay payment message.
3258 """
3259
3260 sender: str = Field(..., description="Handle of the payer")
3261 recipient: str = Field(..., description="Handle of the payee (must match path handle)")
3262 amount_nano: int = Field(..., gt=0, description="Payment amount in nanoMUSE (1 MUSE = 1,000,000,000 nanoMUSE)")
3263 nonce_hex: str = Field(..., min_length=64, max_length=64, description="32-byte random nonce as 64-char hex")
3264 signature: str = Field(..., description="Ed25519 signature over canonical MPay message; 'ed25519:<base64url>'")
3265 sender_public_key: str = Field(..., description="Sender public key; 'ed25519:<base64url>'")
3266 memo: str | None = Field(None, max_length=500, description="Optional payment memo")
3267
3268 class MPayClaimResponse(CamelModel):
3269 """A single MPay claim record."""
3270
3271 claim_id: str
3272 sender: str
3273 recipient: str
3274 amount_nano: int
3275 nonce_hex: str
3276 signature: str
3277 sender_public_key: str
3278 memo: str | None = None
3279 created_at: datetime
3280 confirmed_at: datetime | None = None
3281 voided_at: datetime | None = None
3282
3283 class MPayLedgerResponse(CamelModel):
3284 """MPay ledger for a given handle — sent and received claims."""
3285
3286 handle: str
3287 sent: list[MPayClaimResponse]
3288 received: list[MPayClaimResponse]
3289 total_sent_nano: int
3290 total_received_nano: int
3291
3292 # ---------------------------------------------------------------------------
3293 # Unified archetype-aware profile manifest
3294 # ---------------------------------------------------------------------------
3295
3296 class ActivityDomain(CamelModel):
3297 """52-week × 7-day activity grid for one creative domain.
3298
3299 ``grid`` is a flat list of 364 integers (52 weeks × 7 days, Monday=0).
3300 ``peak`` is the highest single-day count in the grid (for normalisation).
3301 """
3302
3303 domain: str
3304 grid: list[int] # 364 integers (52 weeks × 7 days)
3305 peak: int
3306 total: int
3307
3308 class AttestationBadge(CamelModel):
3309 """Compact attestation badge shown on a profile card."""
3310
3311 attestation_id: str
3312 attester: str
3313 subject: str
3314 claim_type: str
3315 claim: str # raw JSON payload — may contain fields beyond "type"
3316 issued_at: datetime
3317 revoked_at: datetime | None = None
3318 scope: str = "identity"
3319 scope_ref: str | None = None
3320 signature: str | None = None
3321 attester_public_key: str | None = None
3322
3323 class TrustChainEntry(CamelModel):
3324 """One link in an agent's trust chain back to its spawning human."""
3325
3326 handle: str
3327 identity_type: str # "human" | "agent" | "org"
3328 spawned_by: str | None = None
3329
3330 class OrgManifest(CamelModel):
3331 """Org-specific fields rendered on an org profile."""
3332
3333 members: list[str]
3334 quorum: int
3335 treasury_address: str | None = None
3336
3337 class ProfileManifest(CamelModel):
3338 """Archetype-aware profile manifest — the unified profile API response.
3339
3340 Returned by GET /api/profiles/{handle}. ``identity_type`` determines which
3341 optional fields are populated:
3342
3343 * ``"human"`` — ``avax_address``, ``attestations``
3344 * ``"agent"`` — ``agent_model``, ``agent_capabilities``, ``trust_chain``
3345 * ``"org"`` — ``org``
3346 """
3347
3348 # Core identity fields (all archetypes)
3349 identity_id: str
3350 handle: str
3351 identity_type: str # "human" | "agent" | "org"
3352 display_name: str | None = None
3353 bio: str | None = None
3354 avatar_url: str | None = None
3355 location: str | None = None
3356 website_url: str | None = None
3357 social_url: str | None = None
3358 is_verified: bool = False
3359 cc_license: str | None = None
3360 pinned_repo_ids: list[str] = []
3361 repos: list[ProfileRepoSummary] = []
3362 created_at: datetime
3363 updated_at: datetime
3364
3365 # Multi-domain activity canvas (all archetypes; domains present vary)
3366 activity: list[ActivityDomain] = []
3367
3368 # Attestation badges (primarily human / org)
3369 attestations: list[AttestationBadge] = []
3370
3371 # Human-specific
3372 avax_address: str | None = None
3373
3374 # Agent-specific
3375 agent_model: str | None = None
3376 agent_capabilities: list[str] = []
3377 trust_chain: list[TrustChainEntry] = []
3378
3379 # Org-specific
3380 org: OrgManifest | None = None
3381
3382 # MPay ledger summary
3383 mpay_total_sent_nano: int = 0
3384 mpay_total_received_nano: int = 0
File History 6 commits
sha256:3707eba7ad42cadedf18c8b9c534d839b88cfd1c30924c3c5a3edc74e1d809de feat: add url field to mist, issue, and proposal list/read … Sonnet 4.6 minor 7 days ago
sha256:d110dd71fb7c1f5e064162de1262b2976841a00d7549bc4f441045f5c13ef33f feat: add MergeResultEmbed to ProposalResponse (deliverable 5) Sonnet 4.6 minor 8 days ago
sha256:3999d4bb3fa84f8659211aa88a6e01fa9142ffe0cba939ed13ce6ce59810b657 feat: route execute_merge_strategy through STRATEGY_MAP fro… Sonnet 4.6 minor 8 days ago
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor 13 days ago
sha256:af9422a68cbd2db7c88f664388e11134b0ae0057ee5ad14465d82208548a9d7d changing --event to --verdict. displaying changes requested… Human minor 15 days ago
sha256:a909058d727faac4d77f6e659cc0b1f9315efcb6aabfd870d08763525a67093d dialing in --strategy and --history on merge proposal Human minor 15 days ago