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