"""Pydantic v2 request/response models for the MuseHub API. All wire-format fields use camelCase via CamelModel. Python code uses snake_case throughout; only serialisation to JSON uses camelCase. """ import re from datetime import datetime from enum import Enum from typing import Annotated, Literal, NotRequired, TypedDict from pydantic import ConfigDict, Field, ValidationInfo, field_validator, model_validator from musehub.models.base import CamelModel from musehub.types.json_types import JSONObject, StrDict from musehub.types.pydantic_types import PydanticJson type _JsonMeta = dict[str, PydanticJson] # ── Genesis-addressed entity ID validation ──────────────────────────────────── # All first-class entities (repo, issue, proposal, comment, session, release) # carry genesis-addressed IDs of the canonical form :. # # The algorithm prefix is lower-case alphanumeric (e.g. "sha256", "blake3"). # The digest is lowercase hex of at least 32 chars (128-bit minimum). # This pattern accepts any future hash algorithm without a code change — # the prefix encodes the algorithm, exactly as the Muse Cryptographic Codec # specifies. Do NOT tighten this to "sha256" only. _GENESIS_ID_RE: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9]*:[0-9a-f]{32,}$") def _check_genesis_id(field_name: str, v: str) -> str: if not _GENESIS_ID_RE.match(v): raise ValueError( f"invalid {field_name} {v!r}: must be 'sha256:<64 lowercase hex chars>'" ) return v # ── Sync protocol models ────────────────────────────────────────────────────── class CommitInput(CamelModel): """A single commit record transferred in a push payload.""" commit_id: str = Field( ..., description="Content-addressed commit ID (e.g. SHA-256 hex)", examples=["a3f8c1d2e4b5"], ) parent_ids: list[str] = Field( default_factory=list, description="Parent commit IDs; empty for the initial commit", examples=[["b2a7d9e1c3f4"]], ) message: str = Field( ..., max_length=10_000, description="Musical commit message describing the compositional change", examples=["Add dominant 7th chord progression in the bridge — Fm7→Bb7→EbMaj7"], ) snapshot_id: str | None = Field( default=None, description="Optional snapshot ID linking this commit to a stored MIDI artifact", ) timestamp: datetime = Field(..., description="Commit creation time (ISO-8601 UTC)") # Optional -- falls back to the MSign handle when absent author: str | None = Field( default=None, description="Commit author identifier; defaults to the MSign handle when absent", examples=["composer@muse.app"], ) class ObjectInput(CamelModel): """A binary object transferred in a push payload. Content is base64-encoded. For MVP, objects up to ~1 MB are fine; larger files will require pre-signed URL upload in a future release. """ object_id: str = Field(..., description="Content-addressed ID, e.g. 'sha256:abc...'") path: str = Field(..., description="Relative path hint, e.g. 'tracks/jazz_4b.mid'") content_b64: str = Field(..., description="Base64-encoded binary content") class SnapshotInput(CamelModel): """A snapshot manifest transferred in a push payload. A snapshot maps file paths to content-addressed object IDs. Snapshots are idempotent: pushing a snapshot whose ``snapshot_id`` already exists is a no-op. """ snapshot_id: str = Field(..., description="Content-addressed snapshot ID (SHA-256 of sorted path:oid pairs)") manifest: StrDict = Field( default_factory=dict, description="Mapping of relative file path → object_id, e.g. {'tracks/bass.mid': 'sha256:abc...'}", ) created_at: str = Field(default="", description="ISO-8601 UTC creation timestamp") class PushResponse(CamelModel): """Response for POST /musehub/repos/{repo_id}/push.""" ok: bool = Field(..., description="True when the push succeeded", examples=[True]) remote_head: str = Field( ..., description="The new branch head commit ID on the remote after push", examples=["a3f8c1d2e4b5"], ) class ObjectResponse(CamelModel): """A binary object returned in a pull response.""" object_id: str path: str content_b64: str class PullResponse(CamelModel): """Response for POST /musehub/repos/{repo_id}/pull. Pagination is cursor-based: when ``has_more`` is ``True`` the caller should re-issue the pull with ``cursor`` set to ``next_cursor`` to fetch the next page of objects. Commits are always returned in full (typically small); only the object list is paginated. """ commits: list[CommitResponse] objects: list[ObjectResponse] remote_head: str | None has_more: bool = False next_cursor: str | None = None # ── Request models ──────────────────────────────────────────────────────────── class CreateRepoRequest(CamelModel): """Body for POST /musehub/repos — creation wizard. ``owner`` is the URL-visible username that appears in /{owner}/{slug} paths. ``slug`` is auto-generated from ``name`` — lowercase, hyphens, 1–64 chars. Wizard fields: - ``initialize``: when True, an empty "Initial commit" + default branch are created immediately so the repo is browsable right away. - ``default_branch``: branch name used when ``initialize=True``. - ``template_repo_id``: if set, topics/description are copied from that public repo before creation. - ``license``: SPDX identifier or common shorthand (e.g. "CC BY 4.0"). - ``topics``: genre/mood labels analogous to GitHub topics; merged with ``tags`` into a single tag list on the server. """ name: str = Field(..., min_length=1, max_length=255, description="Repo name") owner: str = Field( ..., min_length=1, max_length=64, pattern=r"^[a-z0-9]([a-z0-9\-]{0,62}[a-z0-9])?$", description="URL-safe owner username (lowercase alphanumeric + hyphens, no leading/trailing hyphens)", ) visibility: str = Field("public") description: str = Field("", description="Short description shown on the explore page") tags: list[str] = Field( default_factory=list, description="Free-form tags -- genre, key, instrumentation (e.g. 'jazz', 'F# minor', 'bass')", ) # ── Wizard extensions ──────────────────────────────────────── license: str | None = Field(None, max_length=100, description="License identifier (e.g. 'CC BY 4.0', 'MIT')") topics: list[str] = Field( default_factory=list, description="Genre/mood topic labels merged with tags (e.g. 'classical', 'piano')", ) initialize: bool = Field( True, description="When true, create an initial empty commit + default branch so the repo is immediately browsable", ) default_branch: str = Field( "main", min_length=1, max_length=255, description="Name of the default branch created when initialize=true", ) template_repo_id: str | None = Field( None, description="Genesis-addressed ID of a public repo to copy topics/description/labels from; must be public", ) domain: str = Field( "", description="Domain slug for this repo (e.g. 'code', 'midi', 'audio'). Defaults to 'code' when absent.", ) domain_scoped_id: str | None = Field( None, description="Scoped domain ID (e.g. '@gabriel/midi') — always set when created via /domains/@author/slug/new", ) @field_validator("template_repo_id") @classmethod def _check_template_repo_id(cls, v: str | None) -> str | None: if v is not None: _check_genesis_id("template_repo_id", v) return v # ── Response models ─────────────────────────────────────────────────────────── class RepoResponse(CamelModel): """Wire representation of a MuseHub repo. ``owner`` and ``slug`` together form the canonical /{owner}/{slug} URL scheme. ``repo_id`` is the internal sha256 genesis hash primary key — never exposed in external URLs. """ repo_id: str = Field(..., description="Internal sha256 genesis hash primary key for this repo", examples=["sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"]) name: str = Field(..., description="Human-readable repo name", examples=["jazz-standards-2024"]) owner: str = Field(..., description="URL-visible owner username", examples=["miles_davis"]) slug: str = Field(..., description="URL-safe slug auto-generated from name", examples=["jazz-standards-2024"]) visibility: str = Field(..., description="'public' or 'private'", examples=["public"]) owner_user_id: str = Field(..., description="sha256 genesis hash of the owning user account") clone_url: str = Field(..., description="URL used by the CLI for push/pull", examples=["https://musehub.ai/api/repos/e3b0c44298fc"]) description: str = Field("", description="Short description shown on the explore page", examples=["Classic jazz standards arranged for quartet"]) tags: list[str] = Field(default_factory=list, description="Free-form tags for discovery and search", examples=[["python", "api", "open-source"]]) domain_id: str | None = Field(None, description="ID of the registered Muse domain plugin for this repo") domain: str = Field("generic", description="Human-readable domain slug (e.g. 'code', 'midi', 'audio'). 'generic' when no domain plugin is registered.") default_branch: str = Field("main", description="Default branch name for this repo", examples=["main"]) created_at: datetime = Field(..., description="Repo creation timestamp (ISO-8601 UTC)") updated_at: datetime = Field(..., description="Last metadata update timestamp (ISO-8601 UTC)") pushed_at: datetime | None = Field(None, description="Last push timestamp (ISO-8601 UTC); null if never pushed") @field_validator("repo_id") @classmethod def _check_repo_id(cls, v: str) -> str: return _check_genesis_id("repo_id", v) class TransferOwnershipRequest(CamelModel): """Request body for transferring repo ownership to another user.""" new_owner_user_id: str = Field( ..., description="User ID of the new repo owner", examples=["a1b2c3d4-e5f6-7890-abcd-ef1234567890"] ) class RepoListResponse(CamelModel): """Paginated list of repos for the authenticated user. Covers repos they own plus repos they collaborate on. The ``next_cursor`` opaque string is passed back as ``?cursor=`` to retrieve the next page; a null value means there are no more results. """ repos: list[RepoResponse] = Field(..., description="Repos on this page (up to 20)") next_cursor: str | None = Field(None, description="Pagination cursor — pass as ?cursor= to get the next page") total: int = Field(..., description="Total number of repos across all pages") class BranchResponse(CamelModel): """Wire representation of a branch pointer.""" branch_id: str = Field(..., description="Internal sha256 genesis hash for this branch") name: str = Field(..., description="Branch name", examples=["main", "feat/jazz-bridge"]) head_commit_id: str | None = Field(None, description="HEAD commit ID; null for an empty branch", examples=["a3f8c1d2e4b5"]) class CommitResponse(CamelModel): """Wire representation of a pushed commit.""" commit_id: str = Field(..., description="Content-addressed commit ID", examples=["a3f8c1d2e4b5"]) branch: str = Field(..., description="Branch this commit was pushed to", examples=["main"]) parent_ids: list[str] = Field(..., description="Parent commit IDs", examples=[["b2a7d9e1c3f4"]]) message: str = Field( ..., description="Musical commit message", examples=["Increase tempo from 120→132 BPM in the chorus for more energy"], ) author: str = Field(..., description="Commit author identifier", examples=["composer@muse.app"]) timestamp: datetime = Field(..., description="Commit creation time (ISO-8601 UTC)") snapshot_id: str | None = Field(default=None, description="Optional snapshot artifact ID") class BranchListResponse(CamelModel): """Paginated list of branches.""" branches: list[BranchResponse] class BranchResetRequest(CamelModel): commit_id: str class BranchResetResponse(CamelModel): branch: str commit_id: str previous_commit_id: str class BranchDivergenceScores(CamelModel): """Placeholder musical divergence scores between a branch and the default branch. These five dimensions mirror the ``muse divergence`` command output. Values are floats in [0.0, 1.0] where 0 = identical and 1 = maximally different. All fields are ``None`` when divergence cannot yet be computed server-side (e.g. no audio snapshots attached to commits). """ melodic: float | None = Field(None, description="Melodic divergence (0–1)") harmonic: float | None = Field(None, description="Harmonic divergence (0–1)") rhythmic: float | None = Field(None, description="Rhythmic divergence (0–1)") structural: float | None = Field(None, description="Structural divergence (0–1)") dynamic: float | None = Field(None, description="Dynamic divergence (0–1)") class BranchDetailResponse(CamelModel): """Branch pointer enriched with ahead/behind counts and musical divergence. Used by the branch list page (``GET /{owner}/{repo}/branches``) to give musicians a quick overview of how each branch relates to the default branch. """ branch_id: str = Field(..., description="Internal sha256 genesis hash for this branch") name: str = Field(..., description="Branch name", examples=["main", "feat/jazz-bridge"]) head_commit_id: str | None = Field(None, description="HEAD commit ID; null for an empty branch") is_default: bool = Field(False, description="True when this is the repo's default branch") ahead_count: int = Field(0, ge=0, description="Commits on this branch not yet on the default branch") behind_count: int = Field(0, ge=0, description="Commits on the default branch not yet on this branch") divergence: BranchDivergenceScores = Field( default_factory=lambda: BranchDivergenceScores( melodic=None, harmonic=None, rhythmic=None, structural=None, dynamic=None ), description="Musical divergence scores vs the default branch (placeholder until computable)", ) class BranchDetailListResponse(CamelModel): """List of branches with detail — used by the branch list page and its JSON variant.""" branches: list[BranchDetailResponse] default_branch: str = Field("main", description="Name of the repo's default branch") class TagResponse(CamelModel): """A single tag entry for the tag browser page. Tags are sourced from ``musehub_releases``. The ``namespace`` field is derived from the tag name: ``emotion:happy`` → namespace ``emotion``, ``v1.0`` → namespace ``version``. """ tag: str = Field(..., description="Full tag string (e.g. 'emotion:happy', 'v1.0')") namespace: str = Field(..., description="Namespace prefix (e.g. 'emotion', 'genre', 'version')") commit_id: str | None = Field(None, description="Commit this tag is pinned to") message: str = Field("", description="Release title / description") created_at: datetime = Field(..., description="Tag creation timestamp (ISO-8601 UTC)") class TagListResponse(CamelModel): """All tags for a repo, grouped by namespace. ``namespaces`` is an ordered list of distinct namespace strings present in the repo. ``tags`` is the flat list; clients should filter/group client-side using the ``namespace`` field. """ tags: list[TagResponse] namespaces: list[str] = Field(default_factory=list, description="Distinct namespaces present in this repo") class CommitListResponse(CamelModel): """Cursor-paginated list of commits (newest first). Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page. A null ``nextCursor`` means this is the last page. """ commits: list[CommitResponse] total: int next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) # --------------------------------------------------------------------------- # Snapshots # --------------------------------------------------------------------------- class SnapshotEntryResponse(CamelModel): """One file-tree entry within a snapshot. Maps a workspace-relative path to a content-addressed object ID. ``size_bytes`` is stored at write time and avoids a join to the objects table when rendering the file browser or computing totals. Agent note: ``object_id`` is the key you pass to ``GET /api/repos/{repo_id}/objects/{object_id}/content`` to fetch raw bytes. """ path: str = Field( ..., description="Workspace-relative file path (e.g. 'muse/core/store.py')", examples=["muse/core/store.py"], ) object_id: str = Field( ..., description="Content-addressed object ID — pass to the objects endpoint to download", examples=["a3f8c1d2e4b5690f2c1a8d3e7b9f0142"], ) size_bytes: int = Field( 0, ge=0, description="Stored file size in bytes; 0 when unknown", examples=[4096], ) class SnapshotSummaryResponse(CamelModel): """Lightweight snapshot summary — no file-tree entries. Returned by ``GET /api/repos/{repo_id}/snapshots`` (list view). Use the full-detail endpoint to fetch entries. Agent note: ``entry_count`` tells you how many files are in this snapshot without loading the manifest. Use ``file_count`` for display; fetch ``/snapshots/{snapshot_id}`` when you need the manifest itself. """ snapshot_id: str = Field( ..., description="Content-addressed snapshot ID (SHA-256 of sorted path:oid pairs)", examples=["836cbed7d608984d0f3a2b1c4e5f6a7b"], ) repo_id: str = Field(..., description="Repo this snapshot belongs to") entry_count: int = Field( 0, ge=0, description="Number of file-tree entries (files) tracked at this snapshot", examples=[142], ) total_size_bytes: int = Field( 0, ge=0, description="Sum of all entry size_bytes; 0 when sizes were not recorded", examples=[1048576], ) directories: list[str] = Field( default_factory=list, description="Sorted list of workspace-relative directory paths included in the snapshot hash", examples=[["muse", "muse/core", "muse/cli", "tests"]], ) created_at: datetime = Field(..., description="When this snapshot was first pushed (ISO-8601 UTC)") class SnapshotResponse(CamelModel): """Full snapshot record including all file-tree entries. Returned by ``GET /api/repos/{repo_id}/snapshots/{snapshot_id}``. Agent note: iterate ``entries`` to get the complete ``{path: object_id}`` manifest. For repos with thousands of files, paginate via ``GET /api/repos/{repo_id}/snapshots/{snapshot_id}/entries`` instead. """ snapshot_id: str = Field( ..., description="Content-addressed snapshot ID", examples=["836cbed7d608984d0f3a2b1c4e5f6a7b"], ) repo_id: str = Field(..., description="Repo this snapshot belongs to") directories: list[str] = Field( default_factory=list, description="Sorted workspace-relative directory paths included in the snapshot hash", examples=[["muse", "muse/core"]], ) entries: list[SnapshotEntryResponse] = Field( default_factory=list, description="File-tree entries sorted by path (alphabetical)", ) entry_count: int = Field( 0, ge=0, description="Total number of entries (equal to len(entries) unless paginated)", examples=[142], ) total_size_bytes: int = Field( 0, ge=0, description="Sum of all entry size_bytes", examples=[1048576], ) created_at: datetime = Field(..., description="When this snapshot was first pushed (ISO-8601 UTC)") class SnapshotListResponse(CamelModel): """Cursor-paginated list of snapshot summaries (newest first). Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page. A null ``nextCursor`` means this is the last page. The ``Link: ; rel="next"`` response header carries the same signal for HTTP-native clients. """ snapshots: list[SnapshotSummaryResponse] total: int = Field(..., ge=0, description="Total snapshots in this repo across all pages") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) class SnapshotEntryListResponse(CamelModel): """Cursor-paginated file-tree entries for a single snapshot. Used when the full entry list is too large to return inline. Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page. A null ``nextCursor`` means this is the last page. """ snapshot_id: str entries: list[SnapshotEntryResponse] total: int = Field(..., ge=0, description="Total entries in this snapshot") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) class SnapshotDiffEntry(CamelModel): """A single file-level change between two snapshots. ``status`` values: - ``added``: path present in new snapshot, absent in base - ``removed``: path present in base snapshot, absent in new - ``modified``: path present in both, object_id changed - ``unchanged``: path present in both with identical object_id (only emitted when ``include_unchanged=true`` is requested) Agent note: filter on ``status`` to build targeted summaries — e.g. only ``modified`` entries to see what content changed between two commits. """ path: str = Field(..., description="Workspace-relative file path") status: str = Field( ..., description="Change kind: 'added' | 'removed' | 'modified' | 'unchanged'", examples=["modified"], ) base_object_id: str | None = Field( None, description="Object ID in the base snapshot (null for added files)", examples=["b2a7d9e1c3f4"], ) new_object_id: str | None = Field( None, description="Object ID in the new snapshot (null for removed files)", examples=["c3b8e0f2d5a6"], ) base_size_bytes: int = Field(0, ge=0, description="File size in base snapshot") new_size_bytes: int = Field(0, ge=0, description="File size in new snapshot") class SnapshotDiffResponse(CamelModel): """File-level diff between two snapshots. Returned by ``GET /api/repos/{repo_id}/snapshots/{snapshot_id}/diff?base={base_id}``. Agent note: ``added_count + removed_count + modified_count`` gives the total changed file count. Iterate ``changes`` for path-level detail. Use ``bytes_added`` and ``bytes_removed`` for storage-delta analysis. """ snapshot_id: str = Field(..., description="The 'new' snapshot being compared") base_snapshot_id: str = Field(..., description="The 'base' snapshot being compared against") added_count: int = Field(0, ge=0, description="Files added in new snapshot") removed_count: int = Field(0, ge=0, description="Files removed from base snapshot") modified_count: int = Field(0, ge=0, description="Files present in both but with changed content") unchanged_count: int = Field(0, ge=0, description="Files identical in both snapshots") bytes_added: int = Field(0, ge=0, description="Total bytes added (new file sizes)") bytes_removed: int = Field(0, ge=0, description="Total bytes removed (base file sizes)") changes: list[SnapshotDiffEntry] = Field( default_factory=list, description="Per-file change list, sorted by path", ) class SnapshotBatchRequest(CamelModel): """Request body for the batch snapshot lookup endpoint. Agent note: supply up to 100 snapshot IDs to resolve manifests in a single round-trip instead of N sequential GET requests. """ snapshot_ids: list[str] = Field( ..., min_length=1, max_length=100, description="Up to 100 snapshot IDs to look up", examples=[["836cbed7d608984d", "a1b2c3d4e5f60001"]], ) include_entries: bool = Field( False, description="When true, each result includes its file-tree entries (heavier)", ) class RepoStatsResponse(CamelModel): """Aggregated counts for the repo home page stats bar. Returned by ``GET /api/repos/{repo_id}/stats``. All counts are non-negative integers; 0 when the repo has no data yet. """ commit_count: int = Field(0, ge=0, description="Total number of commits across all branches") branch_count: int = Field(0, ge=0, description="Number of branches (including default)") release_count: int = Field(0, ge=0, description="Number of published releases / tags") # ── Issue models ─────────────────────────────────────────────────────────────── # Reusable constrained element types for issue list fields. _Label = Annotated[str, Field(min_length=1, max_length=100)] _SymbolAnchor = Annotated[str, Field(min_length=1, max_length=500)] _CommitAnchor = Annotated[str, Field(min_length=1, max_length=71)] class IssueCreate(CamelModel): """Body for POST /musehub/repos/{repo_id}/issues.""" title: str = Field( ..., min_length=1, max_length=500, description="Issue title", examples=["Verse chord progression feels unresolved — needs perfect cadence at bar 16"], ) body: str = Field( "", max_length=50_000, description="Issue description (Markdown)", examples=["The Dm→Am→E7→Am progression in the verse doesn't resolve — suggest Dm→G7→CMaj7."], ) labels: list[_Label] = Field( default_factory=list, max_length=20, description="Free-form label strings (max 20, each max 100 chars)", examples=[["harmony", "needs-review"]], ) symbol_anchors: list[_SymbolAnchor] = Field( default_factory=list, max_length=50, description="Symbol addresses to anchor this issue to (max 50, each max 500 chars)", ) commit_anchors: list[_CommitAnchor] = Field( default_factory=list, max_length=50, description="Commit IDs to anchor this issue to (max 50, each max 64 chars)", ) agent_id: str = Field( "", max_length=255, description="Agent identifier when filed by an AI agent (e.g. 'agentception-worker-42')", ) model_id: str = Field( "", max_length=255, description="Model identifier when filed by an AI agent (e.g. 'claude-sonnet-4-6')", ) class IssueUpdate(CamelModel): """Body for PATCH /musehub/repos/{repo_id}/issues/{number} — partial update. All fields are optional; only non-None fields are applied. """ title: str | None = Field(None, min_length=1, max_length=500, description="Updated issue title") body: str | None = Field(None, max_length=50_000, description="Updated issue body (Markdown)") labels: list[_Label] | None = Field(None, max_length=20, description="Replacement label list (max 20, each max 100 chars)") symbol_anchors: list[_SymbolAnchor] | None = Field(None, max_length=50, description="Replacement symbol anchor list (max 50, each max 500 chars)") commit_anchors: list[_CommitAnchor] | None = Field(None, max_length=50, description="Replacement commit anchor list (max 50, each max 64 chars)") agent_id: str | None = Field(None, max_length=255, description="Agent identifier (set when agent-filed)") model_id: str | None = Field(None, max_length=255, description="Model identifier (set when agent-filed)") class IssueResponse(CamelModel): """Wire representation of a MuseHub issue.""" issue_id: str = Field(..., description="Internal sha256 genesis hash for this issue") number: int = Field(..., description="Per-repo sequential issue number", examples=[42]) url: str = Field("", description="Canonical public URL for this issue.") title: str = Field(..., description="Issue title", examples=["Verse chord progression feels unresolved"]) body: str = Field(..., description="Issue description (Markdown)") state: str = Field(..., description="'open' or 'closed'", examples=["open"]) labels: list[str] = Field(..., description="Labels attached to this issue", examples=[["harmony"]]) # Structured symbol anchors: ["file.py::Symbol", …] symbol_anchors: list[str] = Field(default_factory=list, description="Symbol addresses anchored to this issue") # Commit ID anchors: ["a3f2c9ef…", …] commit_anchors: list[str] = Field(default_factory=list, description="Commit IDs anchored to this issue") author: str = "" # Collaborator assigned to resolve this issue; null when unassigned assignee: str | None = Field(None, description="Display name of the assigned collaborator") # Agent provenance — populated when filed by an AI agent agent_id: str = Field("", description="Agent identifier if filed by an AI agent") model_id: str = Field("", description="Model identifier if filed by an AI agent") created_at: datetime = Field(..., description="Issue creation timestamp (ISO-8601 UTC)") updated_at: datetime | None = Field(None, description="Last update timestamp (ISO-8601 UTC)") comment_count: int = Field(0, description="Number of non-deleted comments on this issue") @field_validator("issue_id") @classmethod def _check_issue_id(cls, v: str) -> str: return _check_genesis_id("issue_id", v) class IssueListResponse(CamelModel): """Cursor-paginated list of issues for a repo. Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page. A null ``nextCursor`` means this is the last page. ``total`` reflects the count of all matching issues regardless of the current page so UIs can show "showing N of M" without paginating through everything. The ``Link: ; rel="next"`` response header carries the same signal for HTTP-native clients. """ issues: list[IssueResponse] total: int = Field(0, ge=0, description="Total matching issues across all pages") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) # ── Issue comment models ─────────────────────────────────────────────────────── class IssueCommentCreate(CamelModel): """Body for POST /musehub/repos/{repo_id}/issues/{number}/comments.""" body: str = Field( ..., min_length=1, max_length=50_000, description="Comment body (Markdown).", ) parent_id: str | None = Field( None, description="Parent comment genesis-addressed hash for threaded replies; omit for top-level comments", ) @field_validator("parent_id") @classmethod def _check_parent_id(cls, v: str | None) -> str | None: if v is not None: _check_genesis_id("parent_id", v) return v class IssueCommentResponse(CamelModel): """Wire representation of a single issue comment.""" comment_id: str = Field(..., description="Internal sha256 genesis hash for this comment") issue_id: str = Field(..., description="sha256 genesis hash of the issue this comment belongs to") author: str = Field(..., description="Display name of the comment author") body: str = Field(..., description="Comment body (Markdown)") parent_id: str | None = Field(None, description="Parent comment sha256 genesis hash; null for top-level comments") is_deleted: bool = Field(False, description="True when the comment has been soft-deleted") created_at: datetime = Field(..., description="Comment creation timestamp (ISO-8601 UTC)") updated_at: datetime = Field(..., description="Last edit timestamp (ISO-8601 UTC)") @field_validator("comment_id", "issue_id") @classmethod def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str: return _check_genesis_id(getattr(info, "field_name", "id"), v) class IssueCommentListResponse(CamelModel): """Cursor-paginated discussion on a single issue. Comments are returned in chronological order (oldest first). Top-level comments have ``parent_id=None``; replies reference their parent via ``parent_id``. Clients build the thread tree client-side. Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page. A null ``nextCursor`` means this is the last page. """ comments: list[IssueCommentResponse] total: int next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) # ── Issue assignee models ───────────────────────────────────────────────────── class IssueAssignRequest(CamelModel): """Body for POST /musehub/repos/{repo_id}/issues/{number}/assign.""" assignee: str | None = Field( None, description="Display name or user ID to assign; null to unassign", examples=["miles_davis"], ) class IssueLabelAssignRequest(CamelModel): """Body for POST /musehub/repos/{repo_id}/issues/{number}/labels. Replaces the entire label list on the issue. To append labels, fetch the current list first, merge client-side, and post the merged result. """ labels: list[_Label] = Field( ..., max_length=20, description="Replacement label list for the issue (max 20, each max 100 chars)", examples=[["harmony", "needs-review"]], ) # ── Proposal enums & types ──────────────────────────────────────────────── class ProposalType(str, Enum): """Seven first-class proposal types. Type determines relevant domains, required reviewer archetypes, and valid merge strategies.""" MERGE = "merge" STEM_INTEGRATION = "stem_integration" MIDI_EVOLUTION = "midi_evolution" PAYMENT_SETTLEMENT = "payment_settlement" AGENT_DELEGATION = "agent_delegation" IDENTITY_TRANSITION = "identity_transition" CANONICAL_RELEASE = "canonical_release" class ProposalState(str, Enum): """Seven-state lifecycle. MERGED and ABANDONED are terminal — no transition out.""" DRAFTING = "drafting" OPEN = "open" IN_REVIEW = "in_review" APPROVED = "approved" SETTLING = "settling" MERGED = "merged" ABANDONED = "abandoned" class MergeStrategy(str, Enum): """Merge execution strategy. SELECTIVE requires selective_domains to be set.""" RECURSIVE = "recursive" OVERLAY = "overlay" SNAPSHOT = "snapshot" REPLAY = "replay" WEAVE = "weave" SELECTIVE = "selective" PHASED = "phased" CHERRY_PICK = "cherry_pick" class MergeConditions(CamelModel): """Declarative preconditions that must all be satisfied before a proposal may merge. Evaluated by ``check_merge_conditions()`` in proposal_dag.py. All fields are optional; defaults represent the permissive baseline. Set at the repo level via ``.muse/proposal_defaults.toml`` or overridden per proposal. """ require_approvals: int = Field(2, ge=0, description="Minimum distinct approved reviews") require_domains_approved: list[str] = Field(default_factory=list, description="Each listed domain must have ≥1 approval") max_risk_score: float = Field(1.0, ge=0.0, le=1.0, description="Proposal blocked if aggregate_risk_score exceeds this") require_signed_commits: bool = Field(False, description="All commits on from_branch must carry an Ed25519 proposer_sig_b64") require_no_breakage: bool = Field(False, description="breakage_count must equal 0") require_test_coverage: bool = Field(False, description="test_gap_count must equal 0") require_payment_settled: bool = Field(False, description="For PAYMENT_SETTLEMENT type: on-chain confirmation must be received") require_dependency_merged: bool = Field(True, description="All hard depends_on proposals must be in MERGED state") max_agent_commit_ratio: float = Field(1.0, ge=0.0, le=1.0, description="Maximum fraction of from_branch commits made by agents") class ProposalCommentTarget(CamelModel): """Domain-agnostic coordinate system for dimensional inline comments. Exactly one of the domain-specific groups should be populated. ``target_type='general'`` targets the proposal as a whole (no domain coordinate). """ target_type: str = Field("general", description="general | code | midi | stem | payment | identity") # Code domain symbol_address: str | None = Field(None, description="e.g. 'auth.py::AuthService.login'") line_start: int | None = None line_end: int | None = None # MIDI domain track_name: str | None = None beat_start: float | None = None beat_end: float | None = None note_pitch: int | None = Field(None, ge=0, le=127, description="0–127 MIDI pitch; None = whole region") # Stem domain stem_id: str | None = None timestamp_start: float | None = None timestamp_end: float | None = None # Payment domain nonce_hex: str | None = Field(None, description="Specific MPay claim nonce in the payment chain") # Identity domain identity_handle: str | None = None # Per-domain risk float in [0.0, 1.0], keyed by domain name ("code", "midi", …) DimensionalRiskVector = dict[str, float] # ── Proposal models ──────────────────────────────────────────────────────── class ProposalCreate(CamelModel): """Body for POST /musehub/repos/{repo_id}/proposals.""" title: str = Field( ..., min_length=1, max_length=500, description="Merge proposal title", examples=["Add bossa nova bridge section with 5/4 time signature"], ) from_branch: str = Field( ..., min_length=1, max_length=255, description="Source branch name", examples=["feat/bossa-nova-bridge"], ) to_branch: str = Field( ..., min_length=1, max_length=255, description="Target branch name", examples=["main"], ) body: str = Field( "", max_length=10_000, description="Merge proposal description (Markdown)", examples=["This branch adds an 8-bar bossa nova bridge in 5/4 with guitar and upright bass."], ) proposal_type: ProposalType = Field( ProposalType.MERGE, description="Semantic type of this proposal — governs which merge strategies and conditions apply", ) is_draft: bool = Field( False, description="Draft proposals are open for discussion but cannot be merged", ) merge_conditions: MergeConditions | None = Field( None, description="Override the repo's default merge gate conditions for this proposal", ) merge_strategy: MergeStrategy = Field( MergeStrategy.OVERLAY, description="How conflicting state is resolved when this proposal is merged", ) selective_domains: list[str] | None = Field( None, description="For SELECTIVE strategy: list of domain names to merge (others skipped)", ) depends_on: list[str] = Field( default_factory=list, description="Proposal IDs that must be in MERGED state before this proposal can be merged", ) proposer_public_key: str | None = Field( None, description="Ed25519 public key used to sign this proposal — 'ed25519:'", ) proposer_signature: str | None = Field( None, description="Ed25519 signature over the canonical PROPOSE message — 'ed25519:'", ) proposer_timestamp: str | None = Field( None, description="ISO-8601 UTC timestamp the proposer signed over (must be within ±5 min of server time)", ) class ProposalUpdate(CamelModel): """Body for PATCH /musehub/repos/{repo_id}/proposals/{proposal_id}. All fields are optional — only supplied fields are updated. At least one field must be present. """ title: str | None = Field(None, min_length=1, max_length=500) body: str | None = Field(None, max_length=10_000) proposal_type: ProposalType | None = None merge_strategy: MergeStrategy | None = None model_config = ConfigDict(extra="forbid") @model_validator(mode="after") def at_least_one_field(self) -> "ProposalUpdate": if all(v is None for v in (self.title, self.body, self.proposal_type, self.merge_strategy)): raise ValueError("At least one field must be supplied") return self class MergeResultEmbed(CamelModel): """Merge execution summary embedded in both ProposalResponse and local merge JSON. Agents always read ``data["mergeResult"]`` regardless of which merge path ran. """ status: str = Field(..., description="merged | fast_forward | conflict | up_to_date") commit_id: str | None = Field(None, description="Resulting commit ID, None on conflict or dry-run") strategy: str | None = Field(None, description="Reported strategy name (what the caller passed)") on_conflict: str | None = Field(None, description="Conflict resolution policy: escalate | ours | theirs") history: str | None = Field(None, description="Commit graph style: merge | squash | rebase") conflicts: list[str] = Field(default_factory=list, description="Conflicting file paths") files_changed: dict[str, int] = Field( default_factory=dict, description="{'added': N, 'modified': N, 'deleted': N}", ) semver_impact: str = Field("", description="MAJOR | MINOR | PATCH | empty") class ProposalResponse(CamelModel): """Wire representation of a MuseHub merge proposal.""" proposal_id: str = Field(..., description="Internal sha256 genesis hash for this merge proposal") proposal_number: int = Field(0, description="Per-repo sequential proposal number (1-based)") url: str = Field("", description="Canonical public URL for this proposal.") title: str = Field(..., description="Merge proposal title", examples=["Add feature"]) body: str = Field(..., description="Merge proposal description (Markdown)") state: str = Field(..., description="'open', 'merged', or 'closed'", examples=["open"]) from_branch: str = Field(..., description="Source branch name", examples=["feat/my-thing"]) to_branch: str = Field(..., description="Target branch name", examples=["main"]) merge_commit_id: str | None = Field(default=None, description="Merge commit ID; only set after merge") merged_at: datetime | None = Field(default=None, description="UTC timestamp when the merge proposal was merged; None while open or closed") author: str = "" created_at: datetime = Field(..., description="Merge proposal creation timestamp (ISO-8601 UTC)") proposal_type: ProposalType = Field(ProposalType.MERGE, description="Semantic type of this proposal") is_draft: bool = Field(False, description="Draft proposals cannot be merged") merge_conditions: MergeConditions | None = Field(None, description="Active merge gate conditions for this proposal") merge_strategy: MergeStrategy = Field(MergeStrategy.OVERLAY, description="Conflict resolution strategy") selective_domains: list[str] | None = Field(None, description="Domains targeted for SELECTIVE merge") depends_on: list[str] = Field(default_factory=list, description="Proposal IDs that must merge before this one") risk_score: float | None = Field(None, ge=0.0, le=1.0, description="Aggregate dimensional risk score [0.0, 1.0]") dimensional_risk: DimensionalRiskVector = Field(default_factory=dict, description="Per-domain risk scores") # ── DAG position (populated by get_proposal enrichment) ────────────────── blocked_by: list[int] = Field( default_factory=list, description="proposal_numbers of unmerged direct dependencies blocking this proposal", ) blocks: list[int] = Field( default_factory=list, description="proposal_numbers of proposals that depend on this one", ) is_blocked: bool = Field(False, description="True when len(blocked_by) > 0") # ── Inline simulation summaries (populated by get_proposal) ─────────────── latest_simulations: dict[str, dict] = Field( default_factory=dict, description=( "Latest cached simulation result per type " "(conflict_scan | risk_projection | dependency_order). " "Empty dict when no simulations have been run." ), ) # ── Proposer Ed25519 signature ──────────────────────────────────────────── proposer_signature: str | None = Field(None, description="ed25519: signature over the canonical PROPOSE message") proposer_public_key: str | None = Field(None, description="ed25519: public key of the proposer") # ── Commit anchors ──────────────────────────────────────────────────────── from_commit_id: str | None = Field(None, description="sha256: HEAD commit of from_branch at proposal creation time") to_commit_id: str | None = Field(None, description="sha256: HEAD commit of to_branch at proposal creation time") # ── Snapshot anchors ────────────────────────────────────────────────────── from_snapshot_id: str | None = Field(None, description="sha256: Snapshot (manifest) ID that from_commit_id points to") to_snapshot_id: str | None = Field(None, description="sha256: Snapshot (manifest) ID that to_commit_id points to") # ── Merge execution summary (populated after merge; None while open/closed) ─ merge_result: "MergeResultEmbed | None" = Field(None, description="Merge execution details; populated after merge") @field_validator("proposal_id") @classmethod def _check_proposal_id(cls, v: str) -> str: return _check_genesis_id("proposal_id", v) class ProposalListResponse(CamelModel): """Cursor-paginated list of merge proposals for a repo. Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page. A null ``nextCursor`` means this is the last page. ``total`` reflects the count of all matching proposals regardless of the current page. The ``Link: ; rel="next"`` response header carries the same signal for HTTP-native clients. """ proposals: list[ProposalResponse] total: int = Field(0, ge=0, description="Total matching merge proposals across all pages") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) class ProposalMergeRequest(CamelModel): """Body for POST /musehub/repos/{repo_id}/proposals/{proposal_id}/merge.""" merge_strategy: str = Field( "overlay", description=( "Content merge strategy — how file manifests combine: " "overlay (default), weave, replay, selective." ), ) commit_history: str = Field( "merge", pattern="^(merge|squash|rebase)$", description="VCS commit graph style: merge (default), squash, rebase.", ) class ProposalDiffDimensionScore(CamelModel): """Per-dimension change score between the from_branch and to_branch of a merge proposal. Used by agents to determine which areas changed most significantly before deciding whether to approve or request changes. Scores are Jaccard divergence in [0.0, 1.0]: 0 = identical, 1 = completely different. """ dimension: str = Field( ..., description="Code dimension: interface | data | logic | tests | infrastructure", examples=["interface"], ) score: float = Field(..., ge=0.0, le=1.0, description="Divergence magnitude [0.0, 1.0]") level: str = Field(..., description="Human-readable level: NONE | LOW | MED | HIGH") delta_label: str = Field( ..., description="Formatted delta label for diff badge, e.g. '+2.3' or 'unchanged'", ) description: str = Field(..., description="Human-readable summary of what changed in this dimension") from_branch_commits: int = Field(..., description="Commits in from_branch touching this dimension") to_branch_commits: int = Field(..., description="Commits in to_branch touching this dimension") class ProposalDiffResponse(CamelModel): """Divergence diff between the from_branch and to_branch of a merge proposal. Returned by ``GET /api/repos/{repo_id}/proposals/{proposal_id}/diff``. Consumed by the merge proposal detail page to render dimension badges and divergence scores. Also consumed by AI agents to reason about impact before merging. ``overall_score`` is in [0.0, 1.0]; multiply by 100 for a percentage. ``common_ancestor`` is the merge-base commit ID, or None if histories diverged. """ proposal_id: str = Field(..., description="The merge proposal being inspected") repo_id: str = Field(..., description="The repository containing the merge proposal") from_branch: str = Field(..., description="Source branch name") to_branch: str = Field(..., description="Target branch name") dimensions: list[ProposalDiffDimensionScore] = Field( ..., description="Per-dimension divergence scores (always five entries)" ) overall_score: float | None = Field(None, ge=0.0, le=1.0, description="Mean of all dimension scores in [0.0, 1.0]") common_ancestor: str | None = Field( None, description="Merge-base commit ID; None if no common ancestor" ) affected_sections: list[str] = Field( default_factory=list, description="Musical section names (e.g. Bridge, Chorus) mentioned in commit messages", ) class ProposalMergeResponse(CamelModel): """Confirmation that a merge proposal was merged.""" merged: bool = Field(..., description="True when the merge succeeded", examples=[True]) merge_commit_id: str = Field(..., description="The new merge commit ID", examples=["c9d8e7f6a5b4"]) # ── Proposal review comment models ─────────────────────────────────────────────────── class ProposalCommentCreate(CamelModel): """Body for POST /musehub/repos/{repo_id}/proposals/{proposal_id}/comments. ``target_type`` selects the granularity of the musical annotation: - ``general`` — whole proposal, no positional context - ``track`` — a named instrument track (supply ``target_track``) - ``region`` — beat range within a track (supply track + beat_start/end) - ``note`` — single note event (supply track + beat_start + note_pitch) ``body`` supports Markdown so reviewers can format code-fence chord charts, lists of suggested edits, etc. """ body: str = Field( ..., min_length=1, max_length=10_000, description="Review comment body (Markdown)", examples=["The bass line in beats 16-24 feels rhythmically stiff — try adding some swing."], ) target_type: str = Field( "general", pattern="^(general|track|region|note)$", description="Comment target granularity", examples=["region"], ) target_track: str | None = Field( None, max_length=255, description="Instrument track name for track/region/note targets", examples=["bass"], ) target_beat_start: float | None = Field( None, ge=0, description="First beat of the targeted region (inclusive)", examples=[16.0], ) target_beat_end: float | None = Field( None, ge=0, description="Last beat of the targeted region (exclusive)", examples=[24.0], ) target_note_pitch: int | None = Field( None, ge=0, le=127, description="MIDI pitch (0-127) for note-level targets", examples=[46], ) parent_comment_id: str | None = Field( None, description="Genesis-addressed ID of the parent comment when creating a threaded reply", ) symbol_address: str | None = Field( None, max_length=512, description=( "Symbol address to anchor this comment to (e.g. 'auth.py::AuthService.login'). " "Binds the comment to a specific named symbol in the Symbol Delta. " "Takes precedence over target_type for code-domain proposals." ), examples=["core/engine.py::Engine.process"], ) @field_validator("parent_comment_id") @classmethod def _check_parent_comment_id(cls, v: str | None) -> str | None: if v is not None: _check_genesis_id("parent_comment_id", v) return v class ProposalCommentResponse(CamelModel): """Wire representation of a single proposal review comment.""" comment_id: str = Field(..., description="Internal sha256 genesis hash for this comment") proposal_id: str = Field(..., description="Proposal this comment belongs to") author: str = Field(..., description="Display name / MSign handle of the comment author") author_user_id: str | None = Field(None, description="Content-addressed identity ID of the author") agent_id: str | None = Field(None, description="AI agent identifier; empty/None = human-authored") model_id: str | None = Field(None, description="Model that authored the comment") body: str = Field(..., description="Review body (Markdown)") target_type: str = Field(..., description="'general', 'track', 'region', or 'note'") target_track: str | None = Field(None, description="Instrument track name when targeted") target_beat_start: float | None = Field(None, description="Region start beat (inclusive)") target_beat_end: float | None = Field(None, description="Region end beat (exclusive)") target_note_pitch: int | None = Field(None, description="MIDI pitch for note-level targets") parent_comment_id: str | None = Field(None, description="Parent comment ID for threaded replies") symbol_address: str | None = Field(None, description="Symbol address anchor, if set") created_at: datetime = Field(..., description="Comment creation timestamp (ISO-8601 UTC)") updated_at: datetime | None = Field(None, description="Last edit timestamp; None = never edited") is_deleted: bool = Field(False, description="Soft-delete flag") replies: list[ProposalCommentResponse] = Field( default_factory=list, description="Nested replies to this comment (only populated on top-level comments)", ) @field_validator("comment_id", "proposal_id") @classmethod def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str: return _check_genesis_id(getattr(info, "field_name", "id"), v) class ProposalCommentListResponse(CamelModel): """Cursor-paginated list of review comments for a merge proposal. ``comments`` contains only top-level comments; each carries a ``replies`` list with its direct children, sorted chronologically. This two-level structure covers all current threading requirements without recursive fetches. Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page of top-level comments. A null ``nextCursor`` means this is the last page. """ comments: list[ProposalCommentResponse] = Field( default_factory=list, description="Top-level review comments with nested replies", ) total: int = Field(0, ge=0, description="Total number of comments (all levels)") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) # Rebuild the model to resolve the forward reference in ProposalCommentResponse.replies ProposalCommentResponse.model_rebuild() # ── Proposal reviewer / review models ─────────────────────────────────────────────── class ProposalReviewerRequest(CamelModel): """Body for POST /musehub/repos/{repo_id}/proposals/{proposal_id}/reviewers. Requests a review from one or more users. Each username is added as a ``pending`` review row. Duplicate requests for the same reviewer are idempotent — the state is not reset if the reviewer already submitted. """ reviewers: list[str] = Field( ..., min_length=1, description="List of usernames to request reviews from", examples=[["alice", "bob"]], ) class ProposalReviewResponse(CamelModel): """Wire representation of a single proposal review. ``state`` reflects the current disposition of the reviewer: - ``pending`` — review requested, not yet submitted - ``approved`` — reviewer approved the changes - ``changes_requested`` — reviewer blocked the merge pending fixes - ``dismissed`` — a previous review was dismissed by the merge proposal author ``submitted_at`` is ``None`` while the review is in ``pending`` state. """ id: str = Field(..., description="Internal genesis-addressed hash for this review row") proposal_id: str = Field(..., description="Proposal this review belongs to") reviewer_username: str = Field(..., description="Username of the reviewer") state: str = Field( ..., description="Review state: pending | approved | changes_requested | dismissed", examples=["approved"], ) body: str | None = Field(None, description="Review comment body (Markdown); null for bare assignments") submitted_at: datetime | None = Field(None, description="UTC timestamp when the review was submitted") created_at: datetime = Field(..., description="Row creation timestamp (ISO-8601 UTC)") @field_validator("id", "proposal_id") @classmethod def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str: return _check_genesis_id(getattr(info, "field_name", "id"), v) class ProposalReviewListResponse(CamelModel): """Cursor-paginated list of reviews for a merge proposal. Used by the merge proposal detail page review panel and by AI agents evaluating merge readiness. Includes both pending assignments and submitted reviews. Pass ``nextCursor`` as ``?cursor=`` to advance. A null ``nextCursor`` means this is the last page. """ reviews: list[ProposalReviewResponse] = Field( default_factory=list, description="All review rows for this merge proposal (pending and submitted)", ) total: int = Field(0, ge=0, description="Total number of review rows") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) class ProposalReviewCreate(CamelModel): """Body for POST /musehub/repos/{repo_id}/proposals/{proposal_id}/reviews. Submits a formal review for the authenticated user. If the user was previously assigned as a reviewer, the existing ``pending`` row is updated in-place. If no prior row exists, a new one is created. ``event`` governs the new review state: - ``approve`` → state = approved - ``request_changes`` → state = changes_requested - ``comment`` → state = pending (body-only feedback, no verdict) """ verdict: str = Field( ..., pattern="^(approve|request_changes)$", description="Reviewer verdict: approve | request_changes.", examples=["approve"], ) body: str = Field( "", max_length=10_000, description="Review body (Markdown). Required when verdict='request_changes'.", examples=["Looks good to me."], ) # ── Proposal list enrichment models ────────────────────────────────────────── class ProposalListEntry(CamelModel): """Enriched row for the proposals list view. Produced by ``enrich_proposal_list_entry()`` from a single ``MusehubProposal`` ORM row combined with pre-fetched review and risk data. All computed fields are derived server-side; nothing here comes from the client. Fields prefixed ``domain_`` are per-domain dicts keyed by domain name (e.g. ``"code"``, ``"midi"``). They are only meaningful for domains present in ``active_domains``; callers should check membership before accessing. Invariants: - ``is_blocked`` is always ``len(blocked_by) > 0`` - ``aggregate_risk_score`` is always in ``[0.0, 1.0]`` - ``active_domains`` never contains a domain whose risk score is 0.0 - ``all_merge_conditions_met`` is ``False`` when ``approval_count < required_approvals`` - ``payment_settling`` is ``True`` only when ``state == "settling"`` and ``"pay" in active_domains`` """ # ── Core ───────────────────────────────────────────────────────────────── proposal_id: str = Field(..., description="Full sha256 proposal ID") proposal_number: int = Field(..., description="Per-repo sequential number (1-based)") url: str = Field("", description="Canonical public URL for this proposal.") title: str = Field(..., description="Proposal title, truncated to 80 chars in list view") state: str = Field(..., description="7-state machine value (open, in_review, approved, drafting, settling, merged, abandoned)") proposal_type: str = Field("merge", description="Proposal type label (e.g. merge, midi_evolution)") from_branch: str = Field(..., description="Source branch") to_branch: str = Field(..., description="Target branch") author: str = Field("", description="Author handle") author_type: str = Field("human", description="'human' | 'agent' | 'org' — resolved from MusehubIdentity") created_at: datetime = Field(..., description="Creation timestamp (UTC)") merged_at: datetime | None = Field(None, description="Merge timestamp; None while open") is_draft: bool = Field(False, description="True when proposal is in drafting state") # ── Dimensional activity ────────────────────────────────────────────────── active_domains: list[str] = Field( default_factory=list, description="Domains with non-zero risk or actual diff content (never contains a domain with risk=0.0)", ) domain_risk: dict[str, float] = Field( default_factory=dict, description="Per-domain risk score in [0.0, 1.0], keyed by domain name", ) domain_risk_band: dict[str, str] = Field( default_factory=dict, description="Per-domain risk band ('critical'|'high'|'medium'|'low'), keyed by domain name", ) aggregate_risk_score: float = Field( 0.0, ge=0.0, le=1.0, description="Weighted mean of domain_risk values across active domains", ) aggregate_risk_band: str = Field( "none", description="'critical' (≥0.75) | 'high' (≥0.5) | 'medium' (≥0.25) | 'low' (>0) | 'none' (0.0)", ) # ── Review status ───────────────────────────────────────────────────────── approval_count: int = Field(0, ge=0, description="Number of distinct approved reviews") required_approvals: int = Field( 2, ge=0, description="Approvals needed to satisfy merge conditions; falls back to repo default (2) when merge_conditions is null", ) domains_approved: list[str] = Field( default_factory=list, description="Domains that have received at least one approved review", ) domains_pending_review: list[str] = Field( default_factory=list, description="Active domains that need approval but do not yet have it", ) all_merge_conditions_met: bool = Field( False, description="True iff every merge condition passes (approval count, no breakage, etc.)", ) # ── Dependency position ─────────────────────────────────────────────────── blocked_by: list[int] = Field( default_factory=list, description="proposal_numbers this depends on that are not yet merged", ) blocks: list[int] = Field( default_factory=list, description="proposal_numbers that depend on this proposal", ) is_blocked: bool = Field(False, description="Convenience: len(blocked_by) > 0") # ── Code domain summary ─────────────────────────────────────────────────── symbols_changed: int = Field(0, ge=0, description="Symbol addresses changed in this proposal") breakage_count: int = Field(0, ge=0, description="Structural breakage events detected") test_gap_count: int = Field(0, ge=0, description="Symbols changed without test coverage") touched_symbols_preview: list[str] = Field( default_factory=list, description="Top 3 symbol addresses for hover tooltip (truncated from touched_symbols)", ) # ── MIDI domain summary ─────────────────────────────────────────────────── midi_tracks_changed: int = Field(0, ge=0, description="Number of MIDI tracks modified") midi_notes_delta: int = Field(0, description="Net note count delta (positive = added)") harmonic_tension_delta: float | None = Field( None, description="Change in harmonic tension score; None when not computable", ) # ── Payment domain summary ──────────────────────────────────────────────── payment_claim_count: int = Field(0, ge=0, description="Number of micropayment claims in this proposal") payment_ledger_delta_nano: int = Field(0, description="Net ledger delta in nanoMUSE") payment_avax_address: str | None = Field(None, description="AVAX settlement address when relevant") payment_settling: bool = Field( False, description="True when state='settling' and 'pay' in active_domains", ) # ── Merge strategy ──────────────────────────────────────────────────────── merge_strategy: str = Field( "overlay", description="Conflict resolution strategy for this proposal", ) # ── Agent metadata ──────────────────────────────────────────────────────── agent_model: str | None = Field(None, description="Model ID when author_type='agent'") agent_spawned_by: str | None = Field(None, description="Parent human handle that spawned this agent") # ── Simulation summary (prefetched — zero extra I/O per row) ───────────── simulation_conflict_count: int | None = Field( None, description=( "Conflict count from the latest conflict_scan simulation. " "None when no simulation has been run." ), ) class ProposalListFilters(CamelModel): """Query parameters for the proposals list page and rows fragment. All fields have safe defaults so the page renders correctly with zero query params. ``state`` defaults to ``"open"`` (the most common view). ``limit`` is capped at 100 server-side; requests above that get a 422. Sort values: newest: created_at DESC (default — most recent first) oldest: created_at ASC risk_desc: aggregate_risk_score DESC — critical proposals first risk_asc: aggregate_risk_score ASC — safest proposals first merge_ready_first: all_merge_conditions_met=True first, then risk_desc ``domain`` is repeatable (``?domain=code&domain=midi`` → proposals touching *either* domain). An empty list means all domains. ``proposal_type`` is likewise repeatable. ``assigned_reviewer`` filters to proposals where the given handle has a pending review request. Validated to slug characters only before any DB access. """ state: str = Field( "open", pattern="^(open|in_review|approved|drafting|settling|merged|closed|abandoned|all)$", description="State filter; 'all' returns every state", ) proposal_type: list[str] | None = Field( None, description="Repeatable proposal type filter (e.g. merge, midi_evolution)", ) domain: list[str] | None = Field( None, description="Repeatable domain filter — proposals touching any of these domains", ) risk_band: list[str] | None = Field( None, description="Repeatable risk band filter (critical|high|medium|low)", ) author_type: str = Field( "all", pattern="^(human|agent|org|all)$", description="Filter by author identity type", ) is_blocked: bool | None = Field( None, description="None = all, True = blocked only, False = unblocked only", ) is_draft: bool | None = Field( None, description="None = all, True = drafts only, False = non-draft only", ) merge_strategy: list[str] | None = Field( None, description="Repeatable merge strategy filter (e.g. overlay, weave, phased)", ) assigned_reviewer: str | None = Field( None, pattern=r"^[a-zA-Z0-9_-]{1,64}$", description="Filter to proposals where this handle has a pending review request", ) limit: int = Field(20, ge=1, le=100, description="Page size (max 100)") cursor: str | None = Field(None, description="Opaque pagination cursor from previous response") sort: str = Field( "newest", pattern="^(newest|oldest|risk_desc|risk_asc|merge_ready_first)$", description="Sort order for the proposal list", ) class DomainHeatEntry(CamelModel): """Per-domain activity metrics for the domain heat bar. ``count`` is the number of proposals in the requested state that touch this domain. ``avg_risk`` is the arithmetic mean of non-zero risk_score values for those proposals; it is 0.0 when count is 0. """ count: int = Field(0, ge=0, description="Proposals touching this domain") avg_risk: float = Field(0.0, ge=0.0, le=1.0, description="Mean risk score across those proposals") class DomainHeatResponse(CamelModel): """Domain heat bar data for the proposals list page header. Returned by ``GET /api/repos/{repo_id}/proposals/heat``. ``domains`` maps domain name (e.g. ``"code"``) to its heat entry. Domains with zero matching proposals are omitted from the dict. """ domains: dict[str, DomainHeatEntry] = Field( default_factory=dict, description="Per-domain heat entries; absent key means zero proposals", ) total_open: int = Field(0, ge=0, description="Total proposals in the queried state") class MergeReadinessResponse(CamelModel): """Merge readiness bucketing for the proposals list sidebar widget. Returned by ``GET /api/repos/{repo_id}/proposals/readiness``. Categories: ready: all_merge_conditions_met=True and is_blocked=False blocked: is_blocked=True (regardless of condition status) settling: state='settling' needs_review: not blocked, not settling, conditions not fully met Each list contains proposal numbers (integers), not IDs. """ ready: list[int] = Field(default_factory=list, description="Proposal numbers that can be merged right now") blocked: list[int] = Field(default_factory=list, description="Proposal numbers blocked by unmerged dependencies") settling: list[int] = Field(default_factory=list, description="Proposal numbers awaiting on-chain confirmation") needs_review: list[int] = Field(default_factory=list, description="Proposal numbers pending domain review") # ── Proposal simulation models ──────────────────────────────────────────────── class SimulationType(str, Enum): """Three simulation types supported by the proposal simulation engine. conflict_scan — identify files / domains that will conflict at merge time risk_projection — project post-merge dimensional risk scores per domain dependency_order — Kahn topological sort of the dependency DAG for this proposal """ CONFLICT_SCAN = "conflict_scan" RISK_PROJECTION = "risk_projection" DEPENDENCY_ORDER = "dependency_order" class SimulationResponse(CamelModel): """Cached simulation result for a proposal. Returned by: POST /repos/{repo_id}/proposals/{proposal_id}/simulations/{simulation_type} GET /repos/{repo_id}/proposals/{proposal_id}/simulations/{simulation_type} ``result`` schema depends on ``simulation_type``: conflict_scan → ConflictScanPayload keys risk_projection → RiskProjectionPayload keys dependency_order → DependencyOrderPayload keys ``is_stale`` is True when from_branch has advanced since the simulation ran. """ simulation_id: str = Field(..., description="sha256: content-addressed simulation ID") proposal_id: str = Field(..., description="Proposal this simulation belongs to") simulation_type: str = Field(..., description="One of: conflict_scan | risk_projection | dependency_order") result: PydanticJson = Field(default_factory=dict, description="Simulation payload; schema determined by simulation_type") is_stale: bool = Field(False, description="True when from_branch has advanced since this simulation ran") from_branch_commit_id: str = Field("", description="from_branch tip at the time of the simulation run") duration_ms: int = Field(0, ge=0, description="Wall-clock milliseconds the simulation took to run") created_at: datetime = Field(..., description="UTC timestamp when the simulation was last run") expires_at: datetime | None = Field(None, description="Optional TTL — null means no expiry") class SimulationListResponse(CamelModel): """All simulations run for a single proposal.""" simulations: list[SimulationResponse] = Field(default_factory=list) total: int = Field(0, ge=0) # ── Release models ──────────────────────────────────────────────────────────── class ReleaseCreate(CamelModel): """Body for POST /musehub/repos/{repo_id}/releases. ``tag`` must be unique per repo and must be a valid semver string (e.g. "v1.2.3", "v2.0.0-beta.1"). ``commit_id`` pins the release to a specific commit snapshot. ``channel`` replaces the boolean ``is_prerelease`` flag with a named distribution tier. """ tag: str = Field( ..., min_length=1, max_length=100, description="Semver tag, e.g. 'v1.2.3'", examples=["v1.2.3"] ) title: str = Field( "", max_length=500, description="Release title", examples=["Summer Sessions 2024 — Final Mix"] ) body: str = Field( "", max_length=10_000, description="Release notes (Markdown)", examples=["## Summer Sessions 2024\n\nFinal arrangement with full brass section and 132 BPM tempo."], ) commit_id: str | None = Field( None, description="Commit to pin this release to", examples=["a3f8c1d2e4b5"] ) snapshot_id: str | None = Field( None, description="Snapshot ID for reproducible builds" ) channel: str = Field( "stable", description="Distribution channel: stable | beta | alpha | nightly", examples=["stable"], ) semver_major: int = Field(0, ge=0) semver_minor: int = Field(0, ge=0) semver_patch: int = Field(0, ge=0) semver_pre: str = Field("", max_length=255, description="Pre-release label, e.g. 'beta.1'") semver_build: str = Field("", max_length=255, description="Build metadata, e.g. '20250101'") agent_id: str = Field("", max_length=255) model_id: str = Field("", max_length=255) changelog: list[ChangelogEntryResponse] = Field( default_factory=list, description="Auto-generated changelog entries" ) is_draft: bool = Field(False, description="Save as draft — not yet publicly visible") gpg_signature: str | None = Field( None, description="ASCII-armoured GPG signature for the tag object; omit when unsigned", ) semantic_report: SemanticReleaseReportResponse | None = Field( None, description="Semantic analysis blob computed by the Muse CLI at push time.", ) class ReleaseDownloadUrls(CamelModel): """Structured download package URLs for a release. Each field is either a URL string or None if the package is not available. ``metadata`` is a JSON manifest with release info. """ metadata: str | None = None class ReleaseResponse(CamelModel): """Wire representation of a MuseHub release. ``channel`` surfaces the distribution tier (stable | beta | alpha | nightly). ``is_draft`` hides the release from public listings until published. ``gpg_signature`` is None when unsigned; a non-empty string triggers the verified badge in the UI. ``semantic_report`` is the Muse CLI analysis attached at push time; ``None`` when the release was pushed with ``--no-analysis`` or by an older CLI. """ release_id: str tag: str title: str = "" body: str = "" commit_id: str | None = None snapshot_id: str | None = None channel: str = "stable" semver_major: int = 0 semver_minor: int = 0 semver_patch: int = 0 semver_pre: str = "" semver_build: str = "" download_urls: ReleaseDownloadUrls author: str = "" agent_id: str = "" model_id: str = "" changelog: list[ChangelogEntryResponse] = Field(default_factory=list) is_prerelease: bool = False is_draft: bool = False gpg_signature: str | None = None semantic_report: SemanticReleaseReportResponse | None = None created_at: datetime @field_validator("release_id") @classmethod def _check_release_id(cls, v: str) -> str: return _check_genesis_id("release_id", v) @model_validator(mode="after") def _derive_is_prerelease(self) -> "ReleaseResponse": """Derive is_prerelease from channel: non-stable channels are pre-releases.""" self.is_prerelease = self.channel != "stable" return self class ReleaseListResponse(CamelModel): """Cursor-paginated list of releases for a repo (newest first). Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page. A null ``nextCursor`` means this is the last page. ``total`` reflects the count of all matching releases regardless of the current page. The ``Link: ; rel="next"`` response header carries the same signal for HTTP-native clients. """ releases: list[ReleaseResponse] total: int = Field(0, ge=0, description="Total matching releases across all pages") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) # ── Release asset models ─────────────────────────────────────────────────── class ReleaseAssetCreate(CamelModel): """Body for POST /musehub/repos/{repo_id}/releases/{tag}/assets. ``name`` is the filename shown in the UI (e.g. "summer-v1.0.mid"). ``download_url`` is the pre-signed or CDN URL from which clients download the artifact; Muse stores it verbatim. """ name: str = Field( ..., min_length=1, max_length=500, description="Filename shown in the UI" ) label: str = Field( "", max_length=255, description="Optional human-readable label, e.g. 'MIDI Bundle'", ) content_type: str = Field( "", max_length=128, description="MIME type, e.g. 'audio/midi', 'application/zip'", ) size: int = Field( 0, ge=0, description="File size in bytes; 0 when unknown" ) download_url: str = Field( ..., min_length=1, max_length=2048, description="Direct download URL for the artifact" ) class ReleaseAssetResponse(CamelModel): """Wire representation of a single release asset.""" asset_id: str = Field(..., description="Internal genesis-addressed hash for this asset") release_id: str = Field(..., description="Genesis-addressed hash of the owning release") name: str = Field(..., description="Filename shown in the UI") label: str = Field("", description="Optional human-readable label") content_type: str = Field("", description="MIME type of the artifact") size: int = Field(0, ge=0, description="File size in bytes; 0 when unknown") download_url: str = Field(..., description="Direct download URL") download_count: int = Field(0, ge=0, description="Number of times the asset has been downloaded") created_at: datetime = Field(..., description="Asset creation timestamp (ISO-8601 UTC)") @field_validator("asset_id", "release_id") @classmethod def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str: return _check_genesis_id(getattr(info, "field_name", "id"), v) class ReleaseAssetListResponse(CamelModel): """Cursor-paginated list of assets attached to a release. Agents use this to surface per-asset download counts and direct download URLs on the release detail page without re-fetching the full release. Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page. A null ``nextCursor`` means this is the last page. """ release_id: str tag: str assets: list[ReleaseAssetResponse] total: int = Field(0, ge=0, description="Total assets attached to this release") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) class ReleaseAssetDownloadCount(CamelModel): """Per-asset download count entry in a release download stats response.""" asset_id: str = Field(..., description="Internal sha256 genesis hash for the asset") name: str = Field(..., description="Filename shown in the UI") label: str = Field("", description="Optional human-readable label") download_count: int = Field(0, ge=0, description="Number of times this asset has been downloaded") class ReleaseDownloadStatsResponse(CamelModel): """Download counts per asset for a single release. Returned by ``GET /repos/{repo_id}/releases/{tag}/downloads``. ``total_downloads`` is the sum of ``download_count`` across all assets, providing a quick headline metric without client-side aggregation. """ release_id: str = Field(..., description="sha256 genesis hash of the release") tag: str = Field(..., description="Version tag of the release") assets: list[ReleaseAssetDownloadCount] = Field( default_factory=list, description="Per-asset download counts; empty when no assets have been attached", ) total_downloads: int = Field( 0, ge=0, description="Sum of download_count across all assets" ) # ── Credits models ──────────────────────────────────────────────────────────── class ContributorCredits(CamelModel): """Wire representation of a single contributor's credit record. Aggregated from commit history -- one record per unique author string. Contribution types are inferred from commit message keywords so that an agent or a human can understand each collaborator's role at a glance. """ author: str session_count: int contribution_types: list[str] first_active: datetime last_active: datetime class CreditsResponse(CamelModel): """Wire representation of the full credits roll for a repo. Returned by ``GET /api/repos/{repo_id}/credits``. The ``sort`` field echoes back the sort order applied to the list. An empty ``contributors`` list means no commits have been pushed yet. """ repo_id: str contributors: list[ContributorCredits] sort: str total_contributors: int # ── Object metadata model ───────────────────────────────────────────────────── class ObjectMetaResponse(CamelModel): """Wire representation of a stored artifact -- metadata only, no content bytes. Returned by GET /musehub/repos/{repo_id}/objects. Use the ``/content`` sub-resource to download the raw bytes. The ``path`` field retains the client-supplied relative path hint (e.g. "piano-roll.webp") and is the primary signal for choosing display treatment (.webp → img, etc.). """ object_id: str path: str size_bytes: int created_at: datetime class ObjectMetaListResponse(CamelModel): """List of artifact metadata for a repo.""" objects: list[ObjectMetaResponse] # ── Timeline models ─────────────────────────────────────────────────────────── class TimelineCommitEvent(CamelModel): """A commit plotted as a point on the timeline. Every pushed commit becomes a commit event regardless of its message content. The ``commit_id`` is the canonical identifier for audio-preview lookup and deep-linking to the commit detail page. """ event_type: str = "commit" commit_id: str branch: str message: str author: str timestamp: datetime parent_ids: list[str] class TimelineResponse(CamelModel): """Chronological timeline of commits for a repo. Returns commits oldest-first for temporal rendering. Use ``total_commits`` to show pagination state when the history is truncated. """ commits: list[TimelineCommitEvent] total_commits: int # ── Divergence visualization models ─────────────────────────────────────────── class DivergenceDimensionResponse(CamelModel): """Wire representation of divergence scores for a single musical dimension. Mirrors :class:`musehub.services.musehub_divergence.MuseHubDimensionDivergence` for JSON serialization. AI agents consume this to decide which dimension of a branch needs creative attention before merging. """ dimension: str level: str score: float description: str branch_a_commits: int branch_b_commits: int class DivergenceResponse(CamelModel): """Full musical divergence report between two MuseHub branches. Returned by ``GET /musehub/repos/{repo_id}/divergence``. Contains five per-dimension scores (melodic, harmonic, rhythmic, structural, dynamic) and an overall score computed as the mean of those five scores. The ``overall_score`` is in [0.0, 1.0]; multiply by 100 for a percentage. A score of 0.0 means identical, 1.0 means completely diverged. """ repo_id: str branch_a: str branch_b: str common_ancestor: str | None dimensions: list[DivergenceDimensionResponse] overall_score: float # ── Commit diff summary models ───────────────────────────────────────────────── class CommitDiffDimensionScore(CamelModel): """Per-dimension change score between a commit and its parent. Scores are heuristic estimates derived from the commit message and metadata. They indicate *how much* each musical dimension changed in this commit. """ dimension: str = Field( ..., description="Musical dimension: harmonic | rhythmic | melodic | structural | dynamic", examples=["harmonic"], ) score: float = Field(..., ge=0.0, le=1.0, description="Change magnitude [0.0, 1.0]") label: str = Field(..., description="Human-readable level: none | low | medium | high") color: str = Field( ..., description="CSS class hint for badge colour: dim-none | dim-low | dim-medium | dim-high", ) class CommitDiffSummaryResponse(CamelModel): """Multi-dimensional diff summary between a commit and its parent. Returned by ``GET /api/repos/{repo_id}/commits/{commit_id}/diff-summary``. Consumed by the commit detail page to render dimension-change badges that help musicians understand *what* changed musically between two pushes. """ commit_id: str = Field(..., description="The commit being inspected") parent_id: str | None = Field(None, description="Parent commit ID; None for root commits") dimensions: list[CommitDiffDimensionScore] = Field( ..., description="Per-dimension change scores (always five entries)" ) overall_score: float = Field( ..., ge=0.0, le=1.0, description="Mean across all five dimension scores" ) # ── Explore / Discover models ────────────────────────────────────────────────── class ExploreRepoResult(CamelModel): """A public repo card shown on the explore/discover page. Aggregated counts (commit_count) are computed at query time for efficient pagination and sorting. ``owner`` and ``slug`` together form the /{owner}/{slug} canonical URL. """ repo_id: str name: str owner: str slug: str owner_user_id: str description: str tags: list[str] commit_count: int created_at: datetime pushed_at: datetime | None = None # ── Profile models ──────────────────────────────────────────────────────────── class ProfileUpdateRequest(CamelModel): """Body for PUT /api/users/{username}. All fields are optional -- send only the ones to change. ``is_verified`` and ``cc_license`` are intentionally excluded: they are set by the platform (not self-reported) when an archive upload is approved. """ display_name: str | None = Field(None, max_length=255, description="Human-readable display name") bio: str | None = Field(None, max_length=500, description="Short bio (Markdown supported)") avatar_url: str | None = Field(None, max_length=2048, description="Avatar image URL") location: str | None = Field(None, max_length=255, description="City or region") website_url: str | None = Field(None, max_length=2048, description="Personal website or project URL") social_url: str | None = Field(None, max_length=2048, description="Full URL to a social profile (e.g. https://x.com/handle)") pinned_repo_ids: list[str] | None = Field( None, max_length=6, description="Up to 6 repo_ids to pin on the profile page" ) class ProfileRepoSummary(CamelModel): """Compact repo summary shown on a user's profile page. Includes the last-activity timestamp derived from the most recent commit. ``owner`` and ``slug`` form the /{owner}/{slug} canonical URL for the repo card. """ repo_id: str name: str owner: str slug: str visibility: str domain: str last_activity_at: datetime | None created_at: datetime class ExploreResponse(CamelModel): """Cursor-paginated response from GET /api/discover/repos. ``total`` reflects the full filtered result set size -- not just the current page. Pass ``nextCursor`` as ``?cursor=`` to advance to the next page. A null ``nextCursor`` means this is the last page. """ repos: list[ExploreRepoResult] total: int next_cursor: str | None = None class ProfileResponse(CamelModel): """Full wire representation of a MuseHub user profile. Returned by GET /api/users/{username}. ``repos`` contains only public repos when the caller is not the owner. ``session_credits`` is the total number of commits across all repos (a proxy for creative session activity). CC attribution fields added: ``is_verified`` is True for Public Domain / Creative Commons artists. ``cc_license`` is the SPDX-style license string (e.g. "CC BY 4.0") or None for community users who retain all rights. """ user_id: str username: str display_name: str | None = None bio: str | None = None avatar_url: str | None = None location: str | None = None website_url: str | None = None social_url: str | None = None is_verified: bool = False cc_license: str | None = None pinned_repo_ids: list[str] repos: list[ProfileRepoSummary] session_credits: int created_at: datetime updated_at: datetime # ── Cross-repo search models ─────────────────────────────────────────────────── class GlobalSearchCommitMatch(CamelModel): """A single commit that matched the search query in a cross-repo search. Consumers display ``repo_id`` / ``repo_name`` as the group header, then render ``commit_id``, ``message``, and ``author`` as the match row. """ commit_id: str message: str author: str branch: str timestamp: datetime repo_id: str repo_name: str repo_owner: str repo_visibility: str # ── Webhook models ──────────────────────────────────────────────────────────── # Valid event types a subscriber may register for. WEBHOOK_EVENT_TYPES: frozenset[str] = frozenset( [ "push", "proposal", "issue", "release", "branch", "tag", "session", "analysis", ] ) class WebhookCreate(CamelModel): """Body for POST /musehub/repos/{repo_id}/webhooks. ``events`` must be a non-empty subset of the valid event-type strings (push, proposal, issue, release, branch, tag, session, analysis). ``secret`` is optional; when provided it is used to sign every delivery with HMAC-SHA256 in the ``X-MuseHub-Signature`` header. ``url`` is validated against SSRF rules at parse time (scheme must be https; bare RFC-1918 / loopback IP literals are rejected immediately). Full DNS-resolution validation happens again in the delivery layer as defence in depth against DNS rebinding. """ url: str = Field(..., min_length=1, max_length=2048, description="HTTPS endpoint to deliver events to") events: list[str] = Field(..., min_length=1, description="Event types to subscribe to") secret: str = Field("", description="Optional HMAC-SHA256 signing secret") @field_validator("url") @classmethod def _url_must_be_safe(cls, v: str) -> str: from musehub.security.ssrf import check_url_safe return check_url_safe(v) class WebhookResponse(CamelModel): """Wire representation of a registered webhook subscription.""" webhook_id: str repo_id: str url: str events: list[str] active: bool created_at: datetime @field_validator("webhook_id", "repo_id") @classmethod def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str: return _check_genesis_id(getattr(info, "field_name", "id"), v) class WebhookListResponse(CamelModel): """Cursor-paginated list of webhook subscriptions for a repo. Pass ``nextCursor`` as ``?cursor=`` to retrieve the next page. A null ``nextCursor`` means this is the last page. """ webhooks: list[WebhookResponse] total: int = Field(0, ge=0, description="Total webhooks registered for this repo") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page. Pass verbatim as ?cursor= to advance. " "Null when this is the last page." ), ) class WebhookDeliveryResponse(CamelModel): """Wire representation of a single webhook delivery attempt. ``payload`` is the JSON body that was (or will be) sent to the subscriber. It is stored verbatim so that operators can inspect the exact bytes delivered and so the redeliver endpoint can replay the original payload without guessing. """ delivery_id: str webhook_id: str event_type: str payload: str = Field("", description="JSON body sent to the subscriber URL") attempt: int success: bool response_status: int response_body: str delivered_at: datetime class WebhookDeliveryListResponse(CamelModel): """Cursor-paginated list of delivery attempts for a webhook (newest first). Pass ``nextCursor`` as ``?cursor=`` to retrieve older deliveries. A null ``nextCursor`` means this is the last page. """ deliveries: list[WebhookDeliveryResponse] total: int = Field(0, ge=0, description="Total delivery attempts for this webhook") next_cursor: str | None = Field( None, description=( "Opaque cursor for the next page (older deliveries). " "Pass verbatim as ?cursor= to advance. Null when this is the last page." ), ) class WebhookRedeliverResponse(CamelModel): """Confirmation that a delivery reattempt was executed. ``success`` reflects the final outcome after all retry attempts. ``original_delivery_id`` links back to the delivery row that was replayed. """ original_delivery_id: str = Field(..., description="ID of the original delivery row that was retried") webhook_id: str = Field(..., description="Webhook the payload was redelivered to") event_type: str = Field(..., description="Event type of the redelivered payload") success: bool = Field(..., description="True when the redeliver attempt received a 2xx response") response_status: int = Field(..., description="HTTP status code from the final attempt (0 for network errors)") response_body: str = Field("", description="Response body snippet from the final attempt (≤512 chars)") # ── Webhook event payload TypedDicts ───────────────────────────────────────── # These typed dicts are used as the payload argument to dispatch_event / # dispatch_event_background, replacing JSONObject at the service boundary. class PushEventPayload(TypedDict): """Payload emitted when commits are pushed to a MuseHub repo. Used with event_type="push". """ repoId: str branch: str headCommitId: str pushedBy: str commitCount: int class IssueEventPayload(TypedDict): """Payload emitted when an issue is opened or closed. ``action`` is either ``"opened"`` or ``"closed"``. Used with event_type="issue". """ repoId: str action: str issueId: str number: int title: str state: str class ProposalEventPayload(TypedDict): """Payload emitted when a merge proposal is opened or merged. ``action`` is either ``"opened"`` or ``"merged"``. ``mergeCommitId`` is only present on the "merged" action. Used with event_type="proposal". """ repoId: str action: str proposalId: str title: str fromBranch: str toBranch: str state: str mergeCommitId: NotRequired[str] # Union of all typed webhook event payloads. The dispatcher accepts any of # these; callers pass the specific TypedDict for their event type. WebhookEventPayload = PushEventPayload | IssueEventPayload | ProposalEventPayload # ── Context models ──────────────────────────────────────────────────────────── class MuseHubContextCommitInfo(CamelModel): """Minimal commit metadata included in a MuseHub context document.""" commit_id: str message: str author: str branch: str timestamp: datetime class GlobalSearchRepoGroup(CamelModel): """All matching commits for a single repo, with repo-level metadata. Results are grouped by repo so consumers can render a collapsible section per repo (name, owner) and paginate within each group. ``repo_owner`` + ``repo_slug`` form the canonical /{owner}/{slug} UI URL. """ repo_id: str repo_name: str repo_owner: str repo_slug: str repo_visibility: str matches: list[GlobalSearchCommitMatch] total_matches: int class GlobalSearchResult(CamelModel): """Top-level response for GET /search?q={query}. ``groups`` contains one entry per public repo that had at least one matching commit. ``total_repos_searched`` is the count of repos searched, not just the repos with matches. Pass ``nextCursor`` as ``?cursor=`` to advance to the next page of repo-groups. A null ``nextCursor`` means this is the last page. """ query: str mode: str groups: list[GlobalSearchRepoGroup] total_repos_searched: int next_cursor: str | None = None class MuseHubContextHistoryEntry(CamelModel): """A single ancestor commit in the evolutionary history of the composition. History is built by walking parent_ids from the target commit. Entries are returned newest-first and limited to the last 5 ancestors. """ commit_id: str message: str author: str timestamp: datetime active_tracks: list[str] class MuseHubContextMusicalState(CamelModel): """State at the target commit, derived from stored artifact paths. ``active_tracks`` is populated from object paths in the repo. """ active_tracks: list[str] class MuseHubContextResponse(CamelModel): """Human-readable and agent-consumable musical context document for a commit. Returned by ``GET /api/repos/{repo_id}/context/{ref}``. This is the MuseHub equivalent of ``MuseContextResult`` -- built from the remote repo's commit graph and stored objects rather than the local ``.muse`` filesystem. The structure deliberately mirrors ``MuseContextResult`` so that agents consuming either source see the same schema. Fields: repo_id: The hub repo identifier. current_branch: Branch name for the target commit. head_commit: Metadata for the resolved commit (ref). musical_state: Active tracks and any available musical dimensions. history: Up to 5 ancestor commits, newest-first. missing_elements: Dimensions that could not be determined from stored data. suggestions: Composer-facing hints about what to work on next. """ repo_id: str current_branch: str head_commit: MuseHubContextCommitInfo musical_state: MuseHubContextMusicalState history: list[MuseHubContextHistoryEntry] missing_elements: list[str] suggestions: StrDict # ── In-repo search models ───────────────────────────────────────────────────── class SearchCommitMatch(CamelModel): """A single commit returned by a search query. Carries enough metadata to render a result row and launch an audio preview. The ``score`` field is populated by keyword/recall modes (0–1 overlap ratio); property and grep modes always return 1.0. """ commit_id: str branch: str message: str author: str timestamp: datetime score: float = Field(1.0, ge=0.0, le=1.0, description="Match score (0–1); always 1.0 for exact-match modes") match_source: str = Field("message", description="Where the match was found: 'message', 'branch', or 'property'") class SearchResponse(CamelModel): """Response envelope for all four in-repo search modes. ``mode`` echoes back the requested search mode so clients can render mode-appropriate headers. ``total_scanned`` is the number of commits examined before limit was applied; useful for indicating search depth. """ mode: str query: str matches: list[SearchCommitMatch] total_scanned: int limit: int # ── DAG graph models ─────────────────────────────────────────────────────────── class DagNode(CamelModel): """A single commit node in the repo's directed acyclic graph. Designed for consumption by interactive graph renderers. The ``is_head`` flag marks the current HEAD commit across all branches. ``branch_labels`` and ``tag_labels`` list all ref names pointing at this commit. Muse-specific semantic fields (absent in Git) allow renderers to encode the *type* and *significance* of each commit visually: - ``commit_type``: conventional-commit prefix (feat, fix, refactor, …) - ``sem_ver_bump``: version significance (major, minor, patch, none) - ``is_breaking``: true when the commit contains breaking changes - ``is_agent``: true when committed by an AI agent rather than a human - ``sym_added`` / ``sym_removed``: count of AST symbol operations """ commit_id: str message: str author: str timestamp: datetime branch: str parent_ids: list[str] is_head: bool = False branch_labels: list[str] = Field(default_factory=list) tag_labels: list[str] = Field(default_factory=list) # Muse semantic enrichment commit_type: str = "" sem_ver_bump: str = "none" is_breaking: bool = False is_agent: bool = False sym_added: int = 0 sym_removed: int = 0 class DagEdge(CamelModel): """A directed edge in the commit DAG. ``source`` is the child commit (the one that has the parent). ``target`` is the parent commit. This follows standard graph convention: edge flows from child → parent (newest to oldest). """ source: str target: str class DagGraphResponse(CamelModel): """Topologically sorted commit graph for a MuseHub repo. ``nodes`` are ordered from oldest ancestor to newest commit (Kahn's algorithm). ``edges`` enumerate every parent→child relationship. Consumers can render this directly as a directed acyclic graph without further processing. Agent use case: an AI music agent can use this to identify which branches diverged from a common ancestor, find merge points, and reason about the project's compositional history. """ nodes: list[DagNode] edges: list[DagEdge] head_commit_id: str | None = None # ── Session models ───────────────────────────────────────────────────────────── class SessionCreate(CamelModel): """Body for POST /musehub/repos/{repo_id}/sessions. Sent by the CLI on ``muse session start`` to register a new session. ``started_at`` defaults to the server's current time when absent. """ started_at: datetime | None = Field(default=None, description="Session start time; defaults to server time when absent") participants: list[str] = Field( default_factory=list, description="Participant identifiers or display names", examples=[["miles_davis", "john_coltrane"]], ) intent: str = Field( "", description="Free-text creative goal for this session", examples=["Finish the bossa nova bridge — add percussion and finalize the chord changes"], ) location: str = Field( "", max_length=255, description="Studio or location label", examples=["Blue Note Studio, NYC"], ) is_active: bool = Field(True, description="True if the session is currently live") class SessionStop(CamelModel): """Body for POST /musehub/repos/{repo_id}/sessions/{session_id}/stop. Sent by the CLI on ``muse session stop`` to mark a session as ended. """ ended_at: datetime | None = None class SessionResponse(CamelModel): """Wire representation of a single recording session. ``duration_seconds`` is derived from ``started_at`` and ``ended_at``; None when the session is still active (``ended_at`` is null). ``is_active`` is True while the session is open -- used by the Hub UI to render a live indicator. ``commits`` is the ordered list of Muse commit IDs associated with this session; the UI uses ``len(commits)`` as the commit count badge and the graph page uses it to apply session markers on commit nodes. ``notes`` contains closing markdown notes authored after the session ends. """ session_id: str started_at: datetime ended_at: datetime | None = None duration_seconds: float | None = None participants: list[str] commits: list[str] = Field(default_factory=list, description="Muse commit IDs recorded during this session") notes: str = Field("", description="Closing notes for the session (markdown)") intent: str location: str is_active: bool created_at: datetime @field_validator("session_id") @classmethod def _check_session_id(cls, v: str) -> str: return _check_genesis_id("session_id", v) class SessionListResponse(CamelModel): """Cursor-paginated list of sessions for a repo (newest first). ``next_cursor`` is ``None`` on the last page. Pass it as ``?cursor=`` on the next request to retrieve the following page. """ sessions: list[SessionResponse] total: int next_cursor: str | None = None class ActivityEventResponse(CamelModel): """Wire representation of a single repo-level activity event. ``event_type`` is one of: "commit_pushed" | "proposal_opened" | "proposal_merged" | "proposal_closed" | "issue_opened" | "issue_closed" | "branch_created" | "branch_deleted" | "tag_pushed" | "session_started" | "session_ended" ``metadata`` carries event-specific structured data for deep-link rendering (e.g. ``{"sha": "abc123", "message": "Add groove baseline"}`` for commit_pushed). """ event_id: str repo_id: str event_type: str actor: str description: str metadata: _JsonMeta = Field(default_factory=dict) created_at: datetime # ── User public activity feed models ───────────────────────────────────────── class UserActivityEventItem(CamelModel): """A single event in a user's public activity feed. Uses the public API type vocabulary (push, proposal, issue, release) rather than the internal DB event_type vocabulary (commit_pushed, proposal_opened, …). ``repo`` is the human-readable "{owner}/{slug}" identifier for deep-linking to the repo page without exposing internal repo_id sha256 genesis hashes. ``payload`` carries event-specific structured data (e.g. branch name and head commit message for push events, proposal number and title for proposal events). """ id: str = Field(..., description="Internal ID for this event") type: str = Field( ..., description="Public event type: push | proposal | issue | release", ) actor: str = Field(..., description="Username who triggered the event") repo: str = Field(..., description="Repo identifier as '{owner}/{slug}'") payload: _JsonMeta = Field( default_factory=dict, description="Event-specific structured data for deep-link rendering", ) created_at: datetime = Field(..., description="Event creation timestamp (ISO-8601 UTC)") class UserActivityFeedResponse(CamelModel): """Cursor-paginated public activity feed for a MuseHub user (newest-first). ``events`` contains up to ``limit`` events for the given user, filtered to public repos only (or all repos when the caller is the profile owner). ``next_cursor`` is the event ID to pass as ``before_id`` in the next request to fetch the subsequent page; None when there are no more events. ``type_filter`` echoes back the ``type`` query param, or None when all types are shown. Agent use case: stream this feed to build a real-time view of what a collaborator has been working on across all their public repos. """ events: list[UserActivityEventItem] next_cursor: str | None = Field( None, description="Pass as before_id to fetch the next page; None on the last page", ) type_filter: str | None = Field( None, description="Active type filter value, or None when all types are shown", ) # ── Tree browser models ─────────────────────────────────────────────────────── class TreeEntryResponse(CamelModel): """A single entry (file or directory) in the Muse tree browser. Returned by GET /musehub/repos/{repo_id}/tree/{ref} and GET /musehub/repos/{repo_id}/tree/{ref}/{path}. Consumers should use ``type`` to render the appropriate icon: - "dir" → folder icon, clickable to navigate deeper - "file" → file-type icon based on ``name`` extension (.mid → piano, .mp3/.wav → waveform, .json → braces, .webp/.png → photo) ``size_bytes`` is None for directories (size is the sum of its contents, which the server does not compute at list time). """ type: str = Field(..., description="'file' or 'dir'") name: str = Field(..., description="Entry filename or directory name") path: str = Field(..., description="Full relative path from repo root, e.g. 'tracks/bass.mid'") size_bytes: int | None = Field(None, description="File size in bytes; None for directories") object_id: str | None = Field(None, description="Content-addressed object ID; None for directories and legacy entries") class TreeListResponse(CamelModel): """Directory listing for the Muse tree browser. Returned by GET /musehub/repos/{repo_id}/tree/{ref} and GET /musehub/repos/{repo_id}/tree/{ref}/{path}. Directories are listed before files within the same level. Within each group, entries are sorted alphabetically by name. Agent use case: use this to enumerate files at a known ref without downloading any content. Combine with ``/objects/{object_id}/content`` to read individual files. """ owner: str repo_slug: str ref: str = Field(..., description="The branch name or commit SHA used to resolve the tree") dir_path: str = Field( ..., description="Current directory path being listed; empty string for repo root" ) entries: list[TreeEntryResponse] = Field(default_factory=list) # ── Groove Check models ─────────────────────────────────────────────────────── class GrooveCommitEntry(CamelModel): """Per-commit groove metrics within a groove-check analysis window. groove_score — average note-onset deviation from the quantization grid, measured in beats (lower = tighter to the grid). drift_delta — absolute change in groove_score relative to the prior commit. The oldest commit in the window always has 0.0. status — OK / WARN / FAIL classification against the threshold. """ commit: str = Field(..., description="Short commit reference (8 hex chars)") groove_score: float = Field( ..., description="Average onset deviation from quantization grid, in beats" ) drift_delta: float = Field( ..., description="Absolute change in groove_score vs prior commit" ) status: str = Field(..., description="OK / WARN / FAIL classification") track: str = Field(..., description="Track scope analysed, or 'all'") section: str = Field(..., description="Section scope analysed, or 'all'") midi_files: int = Field(..., description="Number of MIDI snapshots analysed") class BlobMetaResponse(CamelModel): """Wire representation of a single file (blob) in the Muse tree browser. Returned by GET /musehub/repos/{repo_id}/blob/{ref}/{path}. Consumers use ``file_type`` to choose the appropriate rendering mode (piano roll for MIDI, audio player for MP3/WAV, inline img for images, syntax-highlighted text for JSON/XML, hex dump for unknown binaries). ``content_text`` is populated only for text files up to 256 KB; binary files should use ``raw_url`` to stream content. """ object_id: str = Field(..., description="Content-addressed ID, e.g. 'sha256:abc123...'") path: str = Field(..., description="Relative path from repo root, e.g. 'tracks/bass.mid'") filename: str = Field(..., description="Basename of the file, e.g. 'bass.mid'") size_bytes: int = Field(..., description="File size in bytes") sha: str = Field(..., description="Content-addressed SHA identifier") created_at: datetime = Field(..., description="Timestamp when this object was pushed") raw_url: str = Field(..., description="URL to download the raw file bytes") file_type: str = Field( ..., description="Rendering hint: 'midi' | 'audio' | 'json' | 'image' | 'xml' | 'other'", ) content_text: str | None = Field( None, description="UTF-8 content for JSON/XML files up to 256 KB; None for binary or oversized files", ) class GrooveCheckResponse(CamelModel): """Rhythmic consistency dashboard data for a commit range in a MuseHub repo. Aggregates timing deviation, swing ratio, and quantization tightness metrics derived from MIDI snapshots across a window of commits. The ``entries`` list is ordered oldest-first so consumers can plot groove evolution over time. """ commit_range: str = Field(..., description="Commit range string that was analysed") threshold: float = Field( ..., description="Drift threshold in beats used for WARN/FAIL classification" ) total_commits: int = Field(..., description="Total commits in the analysis window") flagged_commits: int = Field( ..., description="Number of commits with WARN or FAIL status" ) worst_commit: str = Field( ..., description="Commit ref with the highest drift_delta, or empty string" ) entries: list[GrooveCommitEntry] = Field( default_factory=list, description="Per-commit metrics, oldest-first", ) # ── Compare view models ──────────────────────────────────────────────────────── class EmotionDiff(CamelModel): """Emotion vector delta between base and head refs. Absolute values (base*/head*) are in [0.0, 1.0]. Delta values (*Delta) are head minus base, in [-1.0, 1.0]. """ base_energy: float = Field(..., ge=0.0, le=1.0, description="Energy score for base ref") head_energy: float = Field(..., ge=0.0, le=1.0, description="Energy score for head ref") base_valence: float = Field(..., ge=0.0, le=1.0, description="Valence score for base ref") head_valence: float = Field(..., ge=0.0, le=1.0, description="Valence score for head ref") energy_delta: float = Field(..., ge=-1.0, le=1.0, description="head_energy - base_energy") valence_delta: float = Field(..., ge=-1.0, le=1.0, description="head_valence - base_valence") tension_delta: float = Field(..., ge=-1.0, le=1.0, description="Change in harmonic tension") darkness_delta: float = Field(..., ge=-1.0, le=1.0, description="Change in modal darkness") class CompareResponse(CamelModel): """Multi-dimensional comparison between two refs in a MuseHub repo. Returned by ``GET /musehub/repos/{repo_id}/compare?base=X&head=Y``. Combines divergence scores and unique commits into a single payload that powers the compare page UI. The ``commits`` list contains only commits reachable from ``head`` but not from ``base`` (i.e. commits unique to head), newest first. """ repo_id: str = Field(..., description="Repository identifier") base_ref: str = Field(..., description="Base ref (branch name, tag, or commit SHA)") head_ref: str = Field(..., description="Head ref (branch name, tag, or commit SHA)") common_ancestor: str | None = Field( default=None, description="Most recent common ancestor commit ID, or null if histories are disjoint", ) dimensions: list[DivergenceDimensionResponse] = Field( ..., description="Per-dimension divergence scores" ) overall_score: float = Field( ..., description="Mean of all dimension scores in [0.0, 1.0]" ) commits: list[CommitResponse] = Field( ..., description="Commits in head not in base (newest first)" ) create_proposal_url: str = Field( ..., description="URL to create a merge proposal from this comparison" ) emotion_diff: EmotionDiff = Field( ..., description="Emotion vector delta between base and head refs" ) # ── Fork models ──────────────────────────────────────────────────────────── class ForkRepoRequest(CamelModel): """Request body for ``POST /api/repos/{repo_id}/fork``. All fields are optional — omitting ``name`` copies the source repo's name. The caller's authenticated handle is always used as the owner; the request body cannot override it. """ name: str | None = Field( None, description=( "Name for the new fork repo. Defaults to the source repo's name. " "A unique slug is auto-generated; use this to disambiguate when you " "already own a repo with the same slug as the source." ), ) description: str | None = Field( None, description=( "Description for the fork. Defaults to the source repo's description " "prefixed with 'Fork of {owner}/{slug}: '." ), ) visibility: Literal["public", "private"] | None = Field( None, description="Visibility for the fork repo. Defaults to 'public'.", ) class UserForkedRepoEntry(CamelModel): """A single forked repo entry shown on a user's profile Forked tab. Combines the fork repo's full metadata with source attribution so the profile page can render "forked from {source_owner}/{source_slug}" under each card. """ fork_id: str = Field(..., description="Genesis-addressed fork relationship ID") fork_repo: RepoResponse = Field(..., description="Full metadata of the forked (child) repo") source_owner: str = Field(..., description="Owner username of the original source repo") source_slug: str = Field(..., description="Slug of the original source repo") forked_at: datetime = Field(..., description="Timestamp when the fork was created (ISO-8601 UTC)") @field_validator("fork_id") @classmethod def _check_fork_id(cls, v: str) -> str: return _check_genesis_id("fork_id", v) class UserForksResponse(CamelModel): """Paginated list of repos forked by a user. Returned by ``GET /api/users/{username}/forks``. """ forks: list[UserForkedRepoEntry] = Field(..., description="Repos forked by this user") total: int = Field(..., description="Total number of forked repos") class ForkNetworkNode(CamelModel): """A single node in the fork network tree. Represents one repo (root or fork) with its owner/slug identity, the number of commits it has diverged from its immediate parent, and its own children in the tree. Used by ``GET /musehub/ui/{owner}/{repo_slug}/forks`` (JSON path) to surface the full network graph for programmatic traversal. """ owner: str = Field(..., description="Owner username of this repo") repo_slug: str = Field(..., description="Slug of this repo") repo_id: str = Field(..., description="sha256 genesis ID of this repo") divergence_commits: int = Field( ..., description="Commits this fork has ahead of its immediate parent (0 for root)", ) forked_by: str = Field( ..., description="User ID who created the fork (empty string for root repo)" ) forked_at: datetime | None = Field( None, description="Timestamp when the fork was created (None for root repo)" ) children: list["ForkNetworkNode"] = Field( default_factory=list, description="Direct forks of this repo, each recursively carrying their own children", ) class ForkNetworkResponse(CamelModel): """Fork network graph for a repo — root with recursive children. Returned by ``GET /musehub/ui/{owner}/{repo_slug}/forks?format=json``. The ``root`` node represents the canonical upstream repo. Each ``ForkNetworkNode`` in ``root.children`` is a direct fork; their own ``children`` lists contain second-level forks, and so on. ``total_forks`` is the flat count of all fork nodes in the tree (excluding the root), so callers can display "N forks" without walking the tree. Agent use case: determine how many downstream forks exist, identify the most-diverged fork before proposing a merge-back proposal, or decide which fork to merge into the root. """ root: ForkNetworkNode = Field(..., description="Root repo (the upstream source)") total_forks: int = Field(..., description="Total number of fork nodes in the network") # Resolve forward reference in self-referential ForkNetworkNode.children ForkNetworkNode.model_rebuild() # ── Render pipeline ──────────────────────────────────────────────────────── class RepoSettingsResponse(CamelModel): """Mutable settings for a MuseHub repo. Returned by ``GET /api/repos/{repo_id}/settings``. Fields map to GitHub-style repo settings. ``name``, ``description``, ``visibility``, and ``topics`` are stored in dedicated repo columns; all remaining flags are stored in the ``settings`` JSON blob. Agent use case: read before updating project metadata, toggling features, or configuring merge strategy for a repo's proposal workflow. """ name: str = Field(..., description="Human-readable repo name") description: str = Field("", description="Short description shown on the explore page") visibility: str = Field(..., description="'public' or 'private'") default_branch: str = Field("main", description="Default branch name (used for clone and proposals)") has_issues: bool = Field(True, description="Whether the issues tracker is enabled") has_projects: bool = Field(False, description="Whether the projects board is enabled") has_wiki: bool = Field(False, description="Whether the wiki is enabled") topics: list[str] = Field(default_factory=list, description="Free-form topic tags") license: str | None = Field(None, description="SPDX license identifier or display name, e.g. 'CC BY 4.0'") homepage_url: str | None = Field(None, description="Project homepage URL") allow_merge_commit: bool = Field(True, description="Allow merge commits on proposals") allow_squash_merge: bool = Field(True, description="Allow squash merges on proposals") allow_rebase_merge: bool = Field(False, description="Allow rebase merges on proposals") delete_branch_on_merge: bool = Field(True, description="Auto-delete head branch after proposal merge") domain_id: str | None = Field(None, description="ID of the Muse domain plugin for this repo") marketplace_domain_id: str | None = Field( None, description=( "ID of a registered MusehubDomain this repo has opted into for its " "domain-specific viewer tab (musehub#117). Distinct from domain_id, " "which is a VCS-plugin category label ('code'/'midi'/'mist')." ), ) class RepoSettingsPatch(CamelModel): """Partial update body for ``PATCH /api/repos/{repo_id}/settings``. All fields are optional — only provided fields are updated. ``visibility`` must be ``'public'`` or ``'private'`` when supplied. Caller must hold owner or admin collaborator permission; otherwise 403 is returned. Agent use case: update repo visibility, merge strategy, or homepage URL without knowing the full settings object. """ name: str | None = Field(None, description="New repo name") description: str | None = Field(None, description="New description") visibility: str | None = Field( None, pattern="^(public|private)$", description="'public' or 'private'", ) default_branch: str | None = Field(None, description="New default branch name") has_issues: bool | None = Field(None, description="Enable/disable issues tracker") has_projects: bool | None = Field(None, description="Enable/disable projects board") has_wiki: bool | None = Field(None, description="Enable/disable wiki") topics: list[str] | None = Field(None, description="Replace topic tags (full list)") license: str | None = Field(None, description="SPDX license identifier or display name") homepage_url: str | None = Field(None, description="Project homepage URL") allow_merge_commit: bool | None = Field(None, description="Allow merge commits on proposals") allow_squash_merge: bool | None = Field(None, description="Allow squash merges on proposals") allow_rebase_merge: bool | None = Field(None, description="Allow rebase merges on proposals") delete_branch_on_merge: bool | None = Field(None, description="Auto-delete head branch after proposal merge") domain_id: str | None = Field(None, description="ID of the Muse domain plugin for this repo") marketplace_domain_id: str | None = Field( None, description=( "Set to a registered MusehubDomain's ID to link this repo to it " "(404 if the domain doesn't exist); set to '' (empty string) to " "clear the link. Omit/None leaves the current link unchanged." ), ) # ── Symbol-level blame models ──────────────────────────────────────────────── class SymbolBlameEntry(CamelModel): """One blame annotation attributing a symbol to the commit that last modified it. Derived from the symbol history index built by ``build_symbol_index``. Each entry represents a named symbol (function, class, variable) in the target file, attributed to the most recent commit that introduced or modified it. """ symbol_address: str = Field(..., description="Full address e.g. 'path/to/file.py::MyFunc'") symbol_name: str = Field(..., description="Short symbol name, e.g. 'MyFunc'") commit_id: str = Field(..., description="Commit that last modified this symbol") commit_message: str = Field(..., description="Message of that commit") author: str = Field(..., description="Author of that commit") timestamp: datetime = Field(..., description="When that commit was made") op: str = Field(..., description="Last operation: 'add' or 'modify'") change_count: int = Field(default=1, description="Total times this symbol has changed") # Intel signals — populated by _build_real_symbol_blame when intel is available is_hotspot: bool = Field(default=False, description="Change count exceeds hotspot threshold") is_dead: bool = Field(default=False, description="Untouched for >= 90 days") is_blast_risk: bool = Field(default=False, description="High co-change count with other symbols") blast_co_symbols: list[str] = Field(default_factory=list, description="Top symbols that co-change with this one") class SymbolBlameResponse(CamelModel): """Response envelope for symbol-level blame.""" entries: list[SymbolBlameEntry] = Field(default_factory=list) total_entries: int = Field(default=0) path: str = Field(default="") # ── Collaborator access-check model ───────────────────────────────────────── class CollaboratorAccessResponse(CamelModel): """Response for the collaborator access-check endpoint. Returns the effective permission level for a given username on a repo. The owner's effective permission is always ``"owner"``. Non-collaborators are reported as 404 rather than returning a ``"none"`` permission value, so callers can distinguish a known absence (404) from a positive result. ``accepted_at`` is ``null`` for the repo owner (ownership is immediate) and for collaborators whose invitation is still pending acceptance. """ username: str = Field(..., description="User identifier supplied in the request path") permission: str = Field( ..., description="Effective permission level: 'read' | 'write' | 'admin' | 'owner'", ) accepted_at: datetime | None = Field( None, description="UTC timestamp when the collaborator accepted the invitation; null for owners", ) class ChangelogEntryResponse(CamelModel): """A single changelog entry auto-generated from commit metadata. Entries are produced by walking the commit graph between releases and extracting ``sem_ver_bump`` and ``breaking_changes`` from each commit's structured metadata. No conventional-commit parsing is required. """ commit_id: str message: str sem_ver_bump: str = "" breaking_changes: list[str] = Field(default_factory=list) author: str = "" timestamp: str = "" class SemanticReleaseReportResponse(CamelModel): """Semantic analysis of a release, computed by the Muse CLI at push time. MuseHub stores this blob verbatim and renders it in the release detail page. All list fields default to ``[]`` and int fields to ``0`` so that a missing or partial report still deserialises cleanly. """ # Snapshot composition languages: list[LanguageStatResponse] = Field(default_factory=list) total_files: int = 0 semantic_files: int = 0 total_symbols: int = 0 symbols_by_kind: list[SymbolKindCountResponse] = Field(default_factory=list) # Delta — what changed in this release vs previous files_changed: int = 0 api_added: list[ApiChangeSummaryResponse] = Field(default_factory=list) api_removed: list[ApiChangeSummaryResponse] = Field(default_factory=list) api_modified: list[ApiChangeSummaryResponse] = Field(default_factory=list) file_hotspots: list[FileHotspotResponse] = Field(default_factory=list) refactor_events: list[RefactorEventResponse] = Field(default_factory=list) # Provenance breaking_changes: list[str] = Field(default_factory=list) human_commits: int = 0 agent_commits: int = 0 unique_agents: list[str] = Field(default_factory=list) unique_models: list[str] = Field(default_factory=list) reviewers: list[str] = Field(default_factory=list) class LanguageStatResponse(CamelModel): """File and symbol counts for a single programming language.""" language: str files: int = 0 symbols: int = 0 class SymbolKindCountResponse(CamelModel): """Count of symbols of a specific kind in the release snapshot.""" kind: str count: int = 0 class ApiChangeSummaryResponse(CamelModel): """A public-API symbol that was added, removed, or modified.""" address: str language: str = "" kind: str = "" change: str = "" # "added" | "removed" | "modified" class FileHotspotResponse(CamelModel): """A file and how many times it was touched across the release's commits.""" file_path: str change_count: int = 0 language: str = "" class RefactorEventResponse(CamelModel): """A single structural refactoring event detected in the release.""" kind: str = "" # "rename" | "move" | "add" | "delete" | "patch" address: str = "" detail: str = "" commit_id: str = "" class WireTagInput(CamelModel): """A single lightweight tag pushed from a Muse CLI client. Wire tags annotate commits with semantic labels (e.g. ``emotion:joyful``, ``section:verse``) that are separate from version releases. The server upserts them — pushing the same tag twice is a no-op. """ tag_id: str = Field(..., description="Genesis-addressed ID for the tag") commit_id: str = Field(..., description="Commit this tag points to") tag: str = Field(..., min_length=1, max_length=500, description="Tag label, e.g. 'emotion:joyful'") created_at: str = Field("", description="ISO-8601 creation timestamp from the client") @field_validator("tag_id", "commit_id") @classmethod def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str: return _check_genesis_id(getattr(info, "field_name", "id"), v) # --------------------------------------------------------------------------- # Agent Fleet — agents deployed by a human identity # --------------------------------------------------------------------------- class AgentCardEntry(CamelModel): """A single agent that has committed to repos owned by a human identity. Aggregated from ``agent_id`` / ``model_id`` columns across all commits in repos owned by the queried handle. ``model_label`` is a human-readable short name derived from ``model_id``. """ agent_id: str model_id: str | None = None model_label: str commit_count: int repo_count: int last_seen: datetime class AgentFleetResponse(CamelModel): """All agents deployed by a given handle, sorted by commit volume.""" handle: str agents: list[AgentCardEntry] total: int # --------------------------------------------------------------------------- # Attestation schemas # --------------------------------------------------------------------------- class AttestationRequest(CamelModel): """Body for POST /api/profiles/{handle}/attestations. The caller provides the pre-computed Ed25519 signature over the canonical ATTEST message so the server can verify without holding any private key. Canonical message (UTF-8, newline separated) for identity scope: ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso} For repo/commit scope, scope_ref is appended: ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}\\n{scope_ref} ``scope`` must be one of: ``identity`` | ``repo`` | ``commit``. ``scope_ref`` is required when scope is ``repo`` or ``commit``. ``commit_id`` (``sha256:...``) is required when scope is ``commit``. ``expires_at`` is optional; expired attestations are excluded from default queries but remain in the DB for audit purposes. """ attester: str = Field(..., description="Handle of the identity issuing the attestation") subject: str = Field(..., description="Handle or slug being attested (handle for identity; handle/repo for repo/commit scope)") claim: str = Field(..., description="JSON claim payload; top-level 'type' key must be a registered claim type") signature: str = Field(..., description="Ed25519 signature over canonical ATTEST message; 'ed25519:'") attester_public_key: str = Field(..., description="Attester public key at time of issuance; 'ed25519:'") issued_at: datetime = Field(..., description="ISO-8601 timestamp of issuance (included in canonical message)") scope: str = Field("identity", description="Attestation scope: 'identity' | 'repo' | 'commit'") scope_ref: str | None = Field(None, description="Full subject reference; required for repo/commit scope (e.g. 'gabriel/musehub@sha256:...')") repo_id: str | None = Field(None, description="Repo slug for repo/commit-scoped attestations") commit_id: str | None = Field(None, description="Content-addressed commit ID ('sha256:...') for commit-scoped attestations") expires_at: datetime | None = Field(None, description="Optional expiry; excluded from live queries after this timestamp") @model_validator(mode="after") def _validate_scope_fields(self) -> "AttestationRequest": """Enforce scope_ref is present for repo/commit scopes.""" if self.scope in ("repo", "commit") and not self.scope_ref: raise ValueError(f"scope_ref is required when scope is '{self.scope}'") if self.scope == "commit" and not self.commit_id: raise ValueError("commit_id is required when scope is 'commit'") return self class AttestationResponse(CamelModel): """A single attestation record as returned by the hub.""" attestation_id: str attester: str subject: str claim: str signature: str attester_public_key: str issued_at: datetime revoked_at: datetime | None = None scope: str = "identity" scope_ref: str | None = None repo_id: str | None = None commit_id: str | None = None expires_at: datetime | None = None class AttestationListResponse(CamelModel): """Paginated list of attestations for a subject, attester, repo, or commit.""" subject: str attestations: list[AttestationResponse] total: int class ClaimTypeRecord(CamelModel): """A single entry from the claim type registry.""" type_key: str category: str label: str description: str valid_scopes: list[str] deprecated_at: datetime | None = None def __getitem__(self, key: str) -> object: return getattr(self, key) class ClaimTypeListResponse(CamelModel): """All registered claim types.""" claim_types: list[ClaimTypeRecord] total: int # --------------------------------------------------------------------------- # MPay claim schemas # --------------------------------------------------------------------------- class MPayClaimRequest(CamelModel): """Body for POST /api/profiles/{handle}/mpay-claims. The sender provides their signature over the canonical MPay payment message. """ sender: str = Field(..., description="Handle of the payer") recipient: str = Field(..., description="Handle of the payee (must match path handle)") amount_nano: int = Field(..., gt=0, description="Payment amount in nanoMUSE (1 MUSE = 1,000,000,000 nanoMUSE)") nonce_hex: str = Field(..., min_length=64, max_length=64, description="32-byte random nonce as 64-char hex") signature: str = Field(..., description="Ed25519 signature over canonical MPay message; 'ed25519:'") sender_public_key: str = Field(..., description="Sender public key; 'ed25519:'") memo: str | None = Field(None, max_length=500, description="Optional payment memo") class MPayClaimResponse(CamelModel): """A single MPay claim record.""" claim_id: str sender: str recipient: str amount_nano: int nonce_hex: str signature: str sender_public_key: str memo: str | None = None created_at: datetime confirmed_at: datetime | None = None voided_at: datetime | None = None class MPayLedgerResponse(CamelModel): """MPay ledger for a given handle — sent and received claims.""" handle: str sent: list[MPayClaimResponse] received: list[MPayClaimResponse] total_sent_nano: int total_received_nano: int # --------------------------------------------------------------------------- # Unified archetype-aware profile manifest # --------------------------------------------------------------------------- class ActivityDomain(CamelModel): """52-week × 7-day activity grid for one creative domain. ``grid`` is a flat list of 364 integers (52 weeks × 7 days, Monday=0). ``peak`` is the highest single-day count in the grid (for normalisation). """ domain: str grid: list[int] # 364 integers (52 weeks × 7 days) peak: int total: int class AttestationBadge(CamelModel): """Compact attestation badge shown on a profile card.""" attestation_id: str attester: str subject: str claim_type: str claim: str # raw JSON payload — may contain fields beyond "type" issued_at: datetime revoked_at: datetime | None = None scope: str = "identity" scope_ref: str | None = None signature: str | None = None attester_public_key: str | None = None class TrustChainEntry(CamelModel): """One link in an agent's trust chain back to its spawning human.""" handle: str identity_type: str # "human" | "agent" | "org" spawned_by: str | None = None class OrgManifest(CamelModel): """Org-specific fields rendered on an org profile.""" members: list[str] quorum: int treasury_address: str | None = None class ProfileManifest(CamelModel): """Archetype-aware profile manifest — the unified profile API response. Returned by GET /api/profiles/{handle}. ``identity_type`` determines which optional fields are populated: * ``"human"`` — ``avax_address``, ``attestations`` * ``"agent"`` — ``agent_model``, ``agent_capabilities``, ``trust_chain`` * ``"org"`` — ``org`` """ # Core identity fields (all archetypes) identity_id: str handle: str identity_type: str # "human" | "agent" | "org" display_name: str | None = None bio: str | None = None avatar_url: str | None = None location: str | None = None website_url: str | None = None social_url: str | None = None is_verified: bool = False cc_license: str | None = None pinned_repo_ids: list[str] = [] repos: list[ProfileRepoSummary] = [] created_at: datetime updated_at: datetime # Multi-domain activity canvas (all archetypes; domains present vary) activity: list[ActivityDomain] = [] # Attestation badges (primarily human / org) attestations: list[AttestationBadge] = [] # Human-specific avax_address: str | None = None # Agent-specific agent_model: str | None = None agent_capabilities: list[str] = [] trust_chain: list[TrustChainEntry] = [] # Org-specific org: OrgManifest | None = None # MPay ledger summary mpay_total_sent_nano: int = 0 mpay_total_received_nano: int = 0