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