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