resources.py
python
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠ breaking
1 day ago
| 1 | """MuseHub MCP Resource catalogue — ``musehub://`` and ``muse://`` URI schemes. |
| 2 | |
| 3 | Resources are side-effect-free, cacheable reads addressable by URI. |
| 4 | Every resource returns ``application/json``. |
| 5 | |
| 6 | ## URI design |
| 7 | |
| 8 | ### Static resources (10): |
| 9 | musehub://trending — top public repos by recent commit activity |
| 10 | musehub://me — authenticated user profile + pinned repos |
| 11 | musehub://me/notifications — unread notification inbox |
| 12 | musehub://me/feed — recent activity feed across followed repos |
| 13 | musehub://me/tokens — active agent tokens for the authenticated user |
| 14 | muse://docs/overview — Muse paradigm overview (State, Commit, Branch, Merge, Drift) |
| 15 | muse://docs/protocol — MuseDomainPlugin protocol spec (all 6 interfaces) |
| 16 | muse://docs/domains — Domain plugin authoring guide |
| 17 | muse://domains — All domains registered on this MuseHub instance |
| 18 | |
| 19 | ### Templated resources (17, RFC 6570 Level 1): |
| 20 | musehub://repos/{owner}/{slug} |
| 21 | musehub://repos/{owner}/{slug}/branches |
| 22 | musehub://repos/{owner}/{slug}/commits |
| 23 | musehub://repos/{owner}/{slug}/commits/{commit_id} |
| 24 | musehub://repos/{owner}/{slug}/tree/{ref} |
| 25 | musehub://repos/{owner}/{slug}/blob/{ref}/{path} |
| 26 | musehub://repos/{owner}/{slug}/issues |
| 27 | musehub://repos/{owner}/{slug}/issues/{number} |
| 28 | musehub://repos/{owner}/{slug}/proposals |
| 29 | musehub://repos/{owner}/{slug}/proposals/{number} |
| 30 | musehub://repos/{owner}/{slug}/releases |
| 31 | musehub://repos/{owner}/{slug}/releases/{tag} |
| 32 | musehub://repos/{owner}/{slug}/insights/{ref} |
| 33 | musehub://repos/{owner}/{slug}/remote |
| 34 | musehub://repos/{owner}/{slug}/timeline |
| 35 | musehub://users/{username} |
| 36 | muse://domains/{author}/{slug} |
| 37 | |
| 38 | ## Ownership / auth |
| 39 | |
| 40 | The dispatcher passes ``user_id`` extracted from the MSign handle (or ``None`` for |
| 41 | anonymous requests). Resource handlers check repo visibility and return a |
| 42 | structured error dict when the caller lacks access. |
| 43 | """ |
| 44 | |
| 45 | import logging |
| 46 | import re |
| 47 | from typing import TypedDict, Required, NotRequired |
| 48 | |
| 49 | from musehub.types.json_types import JSONObject, JSONValue |
| 50 | |
| 51 | logger = logging.getLogger(__name__) |
| 52 | |
| 53 | # ── Catalogue TypedDicts ────────────────────────────────────────────────────── |
| 54 | |
| 55 | class MCPResource(TypedDict, total=False): |
| 56 | """A concrete MCP resource (static URI).""" |
| 57 | |
| 58 | uri: Required[str] |
| 59 | name: Required[str] |
| 60 | description: str |
| 61 | mimeType: str # noqa: N815 |
| 62 | |
| 63 | class MCPResourceTemplate(TypedDict, total=False): |
| 64 | """An MCP resource template (RFC 6570 URI template).""" |
| 65 | |
| 66 | uriTemplate: Required[str] # noqa: N815 |
| 67 | name: Required[str] |
| 68 | description: str |
| 69 | mimeType: str # noqa: N815 |
| 70 | |
| 71 | class MCPResourceContent(TypedDict): |
| 72 | """The content object returned inside a ``resources/read`` response.""" |
| 73 | |
| 74 | uri: str |
| 75 | mimeType: str # noqa: N815 |
| 76 | text: str |
| 77 | |
| 78 | # ── Static resource catalogue ───────────────────────────────────────────────── |
| 79 | |
| 80 | STATIC_RESOURCES: list[MCPResource] = [ |
| 81 | { |
| 82 | "uri": "musehub://trending", |
| 83 | "name": "Trending Repositories", |
| 84 | "description": ( |
| 85 | "Top public MuseHub repositories ranked by recent commit activity across all domains. " |
| 86 | "Use this to discover active state repositories before browsing or forking." |
| 87 | ), |
| 88 | "mimeType": "application/json", |
| 89 | }, |
| 90 | { |
| 91 | "uri": "musehub://me", |
| 92 | "name": "My Profile", |
| 93 | "description": ( |
| 94 | "Authenticated user's profile, public stats, and pinned repositories. " |
| 95 | "Requires authentication." |
| 96 | ), |
| 97 | "mimeType": "application/json", |
| 98 | }, |
| 99 | { |
| 100 | "uri": "musehub://me/notifications", |
| 101 | "name": "My Notifications", |
| 102 | "description": ( |
| 103 | "Unread notification inbox for the authenticated user: " |
| 104 | "Proposal reviews, issue mentions, and new comments. Requires authentication." |
| 105 | ), |
| 106 | "mimeType": "application/json", |
| 107 | }, |
| 108 | { |
| 109 | "uri": "musehub://me/feed", |
| 110 | "name": "My Activity Feed", |
| 111 | "description": ( |
| 112 | "Recent activity (commits, proposals, issues) across repositories the " |
| 113 | "authenticated user follows. Requires authentication." |
| 114 | ), |
| 115 | "mimeType": "application/json", |
| 116 | }, |
| 117 | # ── Muse protocol documentation resources ───────────────────────────────── |
| 118 | { |
| 119 | "uri": "muse://docs/overview", |
| 120 | "name": "Muse Paradigm Overview", |
| 121 | "description": ( |
| 122 | "High-level introduction to the Muse paradigm: State, Commit, Branch, Merge, " |
| 123 | "and Drift. Explains how Muse extends version control from text/code to any " |
| 124 | "multidimensional state space. Essential first read for any agent new to Muse." |
| 125 | ), |
| 126 | "mimeType": "application/json", |
| 127 | }, |
| 128 | { |
| 129 | "uri": "muse://docs/protocol", |
| 130 | "name": "MuseDomainPlugin Protocol Spec", |
| 131 | "description": ( |
| 132 | "Full specification of the MuseDomainPlugin protocol — the six interfaces " |
| 133 | "every domain plugin must implement: StateSerializer, DiffEngine, MergeStrategy, " |
| 134 | "InsightProvider, ViewRenderer, and ArtifactManager. " |
| 135 | "Read this to understand how domains work or to build a new one." |
| 136 | ), |
| 137 | "mimeType": "application/json", |
| 138 | }, |
| 139 | { |
| 140 | "uri": "muse://docs/domains", |
| 141 | "name": "Domain Plugin Authoring Guide", |
| 142 | "description": ( |
| 143 | "Step-by-step guide for authoring and registering a new Muse domain plugin. " |
| 144 | "Covers the MuseDomainPlugin scaffold, capability manifest schema, " |
| 145 | "viewer registration, and publishing to the MuseHub domain registry." |
| 146 | ), |
| 147 | "mimeType": "application/json", |
| 148 | }, |
| 149 | { |
| 150 | "uri": "muse://domains", |
| 151 | "name": "Registered Domain Plugins", |
| 152 | "description": ( |
| 153 | "All domain plugins registered on this MuseHub instance, with their " |
| 154 | "scoped IDs (@author/slug), dimension counts, viewer types, and install counts. " |
| 155 | "Use musehub_list_domains for richer filtering." |
| 156 | ), |
| 157 | "mimeType": "application/json", |
| 158 | }, |
| 159 | { |
| 160 | "uri": "musehub://me/tokens", |
| 161 | "name": "My Active Agent Tokens", |
| 162 | "description": ( |
| 163 | "Active agent identities registered by the authenticated user. " |
| 164 | "Returns identity metadata: agent_name, " |
| 165 | "issued_at, expires_at, and last_used. " |
| 166 | "Run `muse auth keygen` then `muse auth register --agent` to register new identities. " |
| 167 | "Requires authentication." |
| 168 | ), |
| 169 | "mimeType": "application/json", |
| 170 | }, |
| 171 | ] |
| 172 | |
| 173 | # ── Resource template catalogue ─────────────────────────────────────────────── |
| 174 | |
| 175 | RESOURCE_TEMPLATES: list[MCPResourceTemplate] = [ |
| 176 | { |
| 177 | "uriTemplate": "musehub://repos/{owner}/{slug}", |
| 178 | "name": "Repository Overview", |
| 179 | "description": "Metadata, stats, and recent activity for a public repository.", |
| 180 | "mimeType": "application/json", |
| 181 | }, |
| 182 | { |
| 183 | "uriTemplate": "musehub://repos/{owner}/{slug}/branches", |
| 184 | "name": "Repository Branches", |
| 185 | "description": "All branches with their head commit IDs.", |
| 186 | "mimeType": "application/json", |
| 187 | }, |
| 188 | { |
| 189 | "uriTemplate": "musehub://repos/{owner}/{slug}/commits", |
| 190 | "name": "Repository Commits", |
| 191 | "description": "Paginated commit history (newest first) across all branches.", |
| 192 | "mimeType": "application/json", |
| 193 | }, |
| 194 | { |
| 195 | "uriTemplate": "musehub://repos/{owner}/{slug}/commits/{commit_id}", |
| 196 | "name": "Single Commit", |
| 197 | "description": "Detailed commit metadata including parent IDs and artifact snapshot.", |
| 198 | "mimeType": "application/json", |
| 199 | }, |
| 200 | { |
| 201 | "uriTemplate": "musehub://repos/{owner}/{slug}/tree/{ref}", |
| 202 | "name": "File Tree", |
| 203 | "description": "All artifact paths and MIME types at the given branch or commit ref.", |
| 204 | "mimeType": "application/json", |
| 205 | }, |
| 206 | { |
| 207 | "uriTemplate": "musehub://repos/{owner}/{slug}/blob/{ref}/{path}", |
| 208 | "name": "File Metadata", |
| 209 | "description": "Metadata for a single artifact (path, size, MIME type, object ID).", |
| 210 | "mimeType": "application/json", |
| 211 | }, |
| 212 | { |
| 213 | "uriTemplate": "musehub://repos/{owner}/{slug}/issues", |
| 214 | "name": "Issues", |
| 215 | "description": "Open issues for the repository.", |
| 216 | "mimeType": "application/json", |
| 217 | }, |
| 218 | { |
| 219 | "uriTemplate": "musehub://repos/{owner}/{slug}/issues/{number}", |
| 220 | "name": "Single Issue", |
| 221 | "description": "A single issue with its full comment thread.", |
| 222 | "mimeType": "application/json", |
| 223 | }, |
| 224 | { |
| 225 | "uriTemplate": "musehub://repos/{owner}/{slug}/proposals", |
| 226 | "name": "Merge Proposals", |
| 227 | "description": "Merge proposals for the repository.", |
| 228 | "mimeType": "application/json", |
| 229 | }, |
| 230 | { |
| 231 | "uriTemplate": "musehub://repos/{owner}/{slug}/proposals/{number}", |
| 232 | "name": "Single Merge Proposal", |
| 233 | "description": "A single merge proposal with reviews and inline musical comments.", |
| 234 | "mimeType": "application/json", |
| 235 | }, |
| 236 | { |
| 237 | "uriTemplate": "musehub://repos/{owner}/{slug}/releases", |
| 238 | "name": "Releases", |
| 239 | "description": "All releases ordered newest first.", |
| 240 | "mimeType": "application/json", |
| 241 | }, |
| 242 | { |
| 243 | "uriTemplate": "musehub://repos/{owner}/{slug}/releases/{tag}", |
| 244 | "name": "Single Release", |
| 245 | "description": "A specific release by tag with asset download counts.", |
| 246 | "mimeType": "application/json", |
| 247 | }, |
| 248 | { |
| 249 | "uriTemplate": "musehub://repos/{owner}/{slug}/insights/{ref}", |
| 250 | "name": "Domain Insights", |
| 251 | "description": ( |
| 252 | "Domain-specific insight dimensions at a given ref. The dimensions returned " |
| 253 | "are sourced from the repo's domain plugin capabilities — e.g. harmony/rhythm/melody " |
| 254 | "for MIDI repos, or symbols/hotspots/coupling for code repos." |
| 255 | ), |
| 256 | "mimeType": "application/json", |
| 257 | }, |
| 258 | { |
| 259 | "uriTemplate": "musehub://repos/{owner}/{slug}/timeline", |
| 260 | "name": "State Timeline", |
| 261 | "description": ( |
| 262 | "Chronological evolution of the repository's state across all dimensions. " |
| 263 | "Shows commits, branch divergences, and structural milestones over time." |
| 264 | ), |
| 265 | "mimeType": "application/json", |
| 266 | }, |
| 267 | { |
| 268 | "uriTemplate": "musehub://users/{username}", |
| 269 | "name": "User Profile", |
| 270 | "description": "Public profile and list of public repositories for a user.", |
| 271 | "mimeType": "application/json", |
| 272 | }, |
| 273 | { |
| 274 | "uriTemplate": "muse://domains/{author}/{slug}", |
| 275 | "name": "Domain Plugin Manifest", |
| 276 | "description": ( |
| 277 | "Full manifest for a specific registered domain plugin: capabilities, " |
| 278 | "dimensions, viewer type, artifact types, merge semantics, and install instructions. " |
| 279 | "Use {author}=gabriel and {slug}=midi to read the built-in MIDI domain." |
| 280 | ), |
| 281 | "mimeType": "application/json", |
| 282 | }, |
| 283 | { |
| 284 | "uriTemplate": "musehub://repos/{owner}/{slug}/remote", |
| 285 | "name": "Repository Remote Info", |
| 286 | "description": ( |
| 287 | "Remote URL, push/pull API endpoints, and Muse CLI commands for a repository. " |
| 288 | "Returns origin URL, push endpoint, pull endpoint, clone command, " |
| 289 | "and the 'muse remote add origin' command. " |
| 290 | "Equivalent to 'muse remote -v' for a MuseHub repo." |
| 291 | ), |
| 292 | "mimeType": "application/json", |
| 293 | }, |
| 294 | { |
| 295 | "uriTemplate": "musehub://mists/{owner}/{mist_id}", |
| 296 | "name": "Mist", |
| 297 | "description": ( |
| 298 | "Full metadata and content for a single Mist by its content-addressed ID. " |
| 299 | "Public mists are readable by any caller. Secret mists require authentication as the owner. " |
| 300 | "Example: musehub://mists/gabriel/aB3xKq9dPwNm" |
| 301 | ), |
| 302 | "mimeType": "application/json", |
| 303 | }, |
| 304 | { |
| 305 | "uriTemplate": "musehub://mists/{owner}", |
| 306 | "name": "Owner Mist List", |
| 307 | "description": ( |
| 308 | "All public Mists published by a specific user, newest first. " |
| 309 | "Example: musehub://mists/gabriel" |
| 310 | ), |
| 311 | "mimeType": "application/json", |
| 312 | }, |
| 313 | ] |
| 314 | |
| 315 | # ── URI router ──────────────────────────────────────────────────────────────── |
| 316 | |
| 317 | def _err(message: str) -> JSONObject: |
| 318 | return {"error": message} |
| 319 | |
| 320 | async def read_resource(uri: str, user_id: str | None = None) -> JSONObject: |
| 321 | """Dispatch a ``musehub://`` or ``muse://`` URI to the appropriate handler. |
| 322 | |
| 323 | Args: |
| 324 | uri: The URI requested by the MCP client (musehub:// or muse://). |
| 325 | user_id: Authenticated user ID from MSign handle, or ``None`` for anonymous access. |
| 326 | |
| 327 | Returns: |
| 328 | JSON-serialisable dict. On auth/not-found errors, returns |
| 329 | ``{"error": "<message>"}`` — the caller wraps this in an |
| 330 | ``MCPResourceContent`` text block. |
| 331 | """ |
| 332 | # ── muse:// scheme (Muse protocol docs + domain registry) ──────────────── |
| 333 | if uri.startswith("muse://"): |
| 334 | return await _read_muse_resource(uri[len("muse://"):]) |
| 335 | |
| 336 | if not uri.startswith("musehub://"): |
| 337 | return _err(f"Unsupported URI scheme: {uri!r}") |
| 338 | |
| 339 | path = uri[len("musehub://"):] |
| 340 | |
| 341 | # ── Static resources ───────────────────────────────────────────────────── |
| 342 | |
| 343 | if path == "trending": |
| 344 | return await _read_trending() |
| 345 | if path == "me": |
| 346 | return await _read_me(user_id) |
| 347 | if path == "me/notifications": |
| 348 | return await _read_me_notifications(user_id) |
| 349 | if path == "me/feed": |
| 350 | return await _read_me_feed(user_id) |
| 351 | if path == "me/tokens": |
| 352 | return await _read_me_tokens(user_id) |
| 353 | |
| 354 | # ── Templated resources ────────────────────────────────────────────────── |
| 355 | |
| 356 | # musehub://repos/{owner}/{slug}[/...] |
| 357 | m = re.match(r"^repos/([^/]+)/([^/]+)(/.*)?$", path) |
| 358 | if m: |
| 359 | owner, slug, rest = m.group(1), m.group(2), m.group(3) or "" |
| 360 | return await _read_repo_resource(owner, slug, rest, user_id) |
| 361 | |
| 362 | # musehub://users/{username} |
| 363 | m2 = re.match(r"^users/([^/]+)$", path) |
| 364 | if m2: |
| 365 | return await _read_user(m2.group(1)) |
| 366 | |
| 367 | # musehub://mists/{owner}/{mist_id} OR musehub://mists/{owner} |
| 368 | m3 = re.match(r"^mists/([^/]+)(?:/([^/]+))?$", path) |
| 369 | if m3: |
| 370 | mist_owner, mist_id = m3.group(1), m3.group(2) |
| 371 | if mist_id: |
| 372 | return await _read_mist(mist_owner, mist_id, user_id) |
| 373 | return await _read_owner_mists(mist_owner, user_id) |
| 374 | |
| 375 | return _err(f"Unknown resource URI: {uri!r}") |
| 376 | |
| 377 | # ── Resource handlers ───────────────────────────────────────────────────────── |
| 378 | |
| 379 | async def _read_trending() -> JSONObject: |
| 380 | from musehub.services.musehub_mcp_executor import _check_db_available |
| 381 | from musehub.db.database import AsyncSessionLocal |
| 382 | from musehub.services import musehub_discover |
| 383 | |
| 384 | if _check_db_available() is not None: |
| 385 | return _err("Database unavailable") |
| 386 | |
| 387 | try: |
| 388 | async with AsyncSessionLocal() as session: |
| 389 | explore = await musehub_discover.list_public_repos(session, sort="activity", page_size=20) |
| 390 | return { |
| 391 | "trending": [ |
| 392 | { |
| 393 | "repo_id": r.repo_id, |
| 394 | "owner": r.owner, |
| 395 | "slug": r.slug, |
| 396 | "name": r.name, |
| 397 | "description": r.description, |
| 398 | "tags": list(r.tags) if r.tags else [], |
| 399 | "commit_count": r.commit_count, |
| 400 | "created_at": r.created_at.isoformat() if r.created_at else None, |
| 401 | } |
| 402 | for r in explore.repos |
| 403 | ] |
| 404 | } |
| 405 | except Exception as exc: |
| 406 | logger.exception("trending resource failed: %s", exc) |
| 407 | return _err(str(exc)) |
| 408 | |
| 409 | async def _read_me(user_id: str | None) -> JSONObject: |
| 410 | if user_id is None: |
| 411 | return _err("Authentication required for musehub://me") |
| 412 | from musehub.services.musehub_mcp_executor import _check_db_available |
| 413 | from musehub.db.database import AsyncSessionLocal |
| 414 | from musehub.services import musehub_repository |
| 415 | |
| 416 | if _check_db_available() is not None: |
| 417 | return _err("Database unavailable") |
| 418 | |
| 419 | try: |
| 420 | async with AsyncSessionLocal() as session: |
| 421 | repo_list = await musehub_repository.list_repos_for_user(session, user_id, limit=20) |
| 422 | return { |
| 423 | "user_id": user_id, |
| 424 | "repos": [ |
| 425 | { |
| 426 | "repo_id": r.repo_id, |
| 427 | "slug": r.slug, |
| 428 | "name": r.name, |
| 429 | "visibility": r.visibility, |
| 430 | } |
| 431 | for r in repo_list.repos |
| 432 | ], |
| 433 | } |
| 434 | except Exception as exc: |
| 435 | logger.exception("me resource failed: %s", exc) |
| 436 | return _err(str(exc)) |
| 437 | |
| 438 | async def _read_me_notifications(user_id: str | None) -> JSONObject: |
| 439 | if user_id is None: |
| 440 | return _err("Authentication required for musehub://me/notifications") |
| 441 | return {"user_id": user_id, "notifications": [], "note": "Notification inbox coming soon."} |
| 442 | |
| 443 | async def _read_me_feed(user_id: str | None) -> JSONObject: |
| 444 | if user_id is None: |
| 445 | return _err("Authentication required for musehub://me/feed") |
| 446 | return {"user_id": user_id, "events": [], "note": "Activity feed coming soon."} |
| 447 | |
| 448 | async def _read_repo_resource( |
| 449 | owner: str, |
| 450 | slug: str, |
| 451 | rest: str, |
| 452 | user_id: str | None, |
| 453 | ) -> JSONObject: |
| 454 | """Route repo sub-resources once owner/slug have been extracted.""" |
| 455 | from musehub.services.musehub_mcp_executor import _check_db_available |
| 456 | from musehub.db.database import AsyncSessionLocal |
| 457 | from musehub.services import musehub_repository |
| 458 | |
| 459 | if _check_db_available() is not None: |
| 460 | return _err("Database unavailable") |
| 461 | |
| 462 | try: |
| 463 | async with AsyncSessionLocal() as session: |
| 464 | repo = await musehub_repository.get_repo_by_owner_slug(session, owner, slug) |
| 465 | if repo is None: |
| 466 | return _err(f"Repository '{owner}/{slug}' not found.") |
| 467 | if repo.visibility != "public" and user_id is None: |
| 468 | return _err(f"Repository '{owner}/{slug}' is private. Authentication required.") |
| 469 | |
| 470 | repo_id = repo.repo_id |
| 471 | |
| 472 | if rest == "" or rest == "/": |
| 473 | return await _repo_overview(session, repo, repo_id) |
| 474 | if rest == "/branches": |
| 475 | return await _repo_branches(session, repo_id) |
| 476 | if rest == "/commits": |
| 477 | return await _repo_commits(session, repo_id) |
| 478 | if rest == "/issues": |
| 479 | return await _repo_issues(session, repo_id) |
| 480 | if rest == "/proposals": |
| 481 | return await _repo_pulls(session, repo_id) |
| 482 | if rest == "/releases": |
| 483 | return await _repo_releases(session, repo_id) |
| 484 | |
| 485 | m = re.match(r"^/commits/(.+)$", rest) |
| 486 | if m: |
| 487 | return await _repo_commit(session, repo_id, m.group(1)) |
| 488 | |
| 489 | m = re.match(r"^/tree/([^/]+)$", rest) |
| 490 | if m: |
| 491 | return await _repo_tree(session, repo_id, m.group(1)) |
| 492 | |
| 493 | m = re.match(r"^/blob/([^/]+)/(.+)$", rest) |
| 494 | if m: |
| 495 | return await _repo_blob(session, repo_id, m.group(1), m.group(2)) |
| 496 | |
| 497 | m = re.match(r"^/issues/(\d+)$", rest) |
| 498 | if m: |
| 499 | return await _repo_issue(session, repo_id, int(m.group(1))) |
| 500 | |
| 501 | m = re.match(r"^/proposals/(.+)$", rest) |
| 502 | if m: |
| 503 | return await _repo_pull(session, repo_id, m.group(1)) |
| 504 | |
| 505 | m = re.match(r"^/releases/(.+)$", rest) |
| 506 | if m: |
| 507 | return await _repo_release_by_tag(session, repo_id, m.group(1)) |
| 508 | |
| 509 | m = re.match(r"^/insights/(.+)$", rest) |
| 510 | if m: |
| 511 | return await _repo_insights(session, repo_id, m.group(1)) |
| 512 | |
| 513 | # Legacy path: redirect analysis to insights |
| 514 | m = re.match(r"^/analysis/(.+)$", rest) |
| 515 | if m: |
| 516 | return await _repo_insights(session, repo_id, m.group(1)) |
| 517 | |
| 518 | if rest == "/timeline": |
| 519 | return await _repo_timeline(session, repo_id) |
| 520 | |
| 521 | if rest == "/remote": |
| 522 | return _repo_remote_info(repo, repo_id) |
| 523 | |
| 524 | return _err(f"Unknown resource path: musehub://repos/{owner}/{slug}{rest}") |
| 525 | except Exception as exc: |
| 526 | logger.exception("repo resource failed (%s/%s%s): %s", owner, slug, rest, exc) |
| 527 | return _err(str(exc)) |
| 528 | |
| 529 | # ── Sub-resource helpers ────────────────────────────────────────────────────── |
| 530 | |
| 531 | def _repo_remote_info(repo: "RepoResponse", repo_id: str) -> JSONObject: |
| 532 | """Return remote URL and push/pull endpoints for a repository.""" |
| 533 | from musehub.models.musehub import RepoResponse |
| 534 | |
| 535 | hub_url = "https://musehub.ai" |
| 536 | remote_url = f"{hub_url}/{repo.owner}/{repo.slug}" |
| 537 | api_base = f"{hub_url}/api/repos/{repo_id}" |
| 538 | |
| 539 | return { |
| 540 | "repo_id": repo_id, |
| 541 | "name": "origin", |
| 542 | "remote_url": remote_url, |
| 543 | "push_url": f"{api_base}/push", |
| 544 | "pull_url": f"{api_base}/pull", |
| 545 | "clone_command": f"muse clone {remote_url}", |
| 546 | "add_remote_command": f"muse remote add origin {remote_url}", |
| 547 | } |
| 548 | |
| 549 | async def _read_me_tokens(user_id: str | None) -> JSONObject: |
| 550 | """Return active agent token metadata for the authenticated user.""" |
| 551 | if user_id is None: |
| 552 | return _err("Authentication required for musehub://me/tokens") |
| 553 | # Agent identities use Ed25519 key pairs registered via muse auth. |
| 554 | # Return guidance on how to create/manage identities instead. |
| 555 | return { |
| 556 | "user_id": user_id, |
| 557 | "note": ( |
| 558 | "Agent identities use Ed25519 key pairs. Run `muse auth keygen` then " |
| 559 | "`muse auth register --agent` to register a new agent identity." |
| 560 | ), |
| 561 | } |
| 562 | |
| 563 | async def _repo_overview(session: AsyncSession, repo: "RepoResponse", repo_id: str) -> JSONObject: |
| 564 | from musehub.services import musehub_repository |
| 565 | from musehub.models.musehub import RepoResponse # local import |
| 566 | |
| 567 | branches = await musehub_repository.list_branches(session, repo_id) |
| 568 | commits_result = await musehub_repository.list_commits(session, repo_id, limit=5) |
| 569 | |
| 570 | return { |
| 571 | "repo_id": repo_id, |
| 572 | "owner": repo.owner, |
| 573 | "slug": repo.slug, |
| 574 | "name": repo.name, |
| 575 | "description": repo.description, |
| 576 | "visibility": repo.visibility, |
| 577 | "tags": list(repo.tags) if repo.tags else [], |
| 578 | "domain_id": getattr(repo, "domain_id", None), |
| 579 | "domain_meta": dict(getattr(repo, "domain_meta", {}) or {}), |
| 580 | "branch_count": len(branches), |
| 581 | "total_commits": commits_result.total, |
| 582 | "recent_commits": [ |
| 583 | { |
| 584 | "commit_id": c.commit_id, |
| 585 | "branch": c.branch, |
| 586 | "message": c.message, |
| 587 | "author": c.author, |
| 588 | "timestamp": c.timestamp.isoformat(), |
| 589 | } |
| 590 | for c in commits_result.commits |
| 591 | ], |
| 592 | "created_at": repo.created_at.isoformat() if repo.created_at else None, |
| 593 | } |
| 594 | |
| 595 | async def _repo_branches(session: AsyncSession, repo_id: str) -> JSONObject: |
| 596 | from musehub.services import musehub_repository |
| 597 | branches = await musehub_repository.list_branches(session, repo_id) |
| 598 | return { |
| 599 | "repo_id": repo_id, |
| 600 | "branches": [ |
| 601 | {"name": b.name, "head_commit_id": b.head_commit_id} |
| 602 | for b in branches |
| 603 | ], |
| 604 | } |
| 605 | |
| 606 | async def _repo_commits(session: AsyncSession, repo_id: str) -> JSONObject: |
| 607 | from musehub.services import musehub_repository |
| 608 | commits_result = await musehub_repository.list_commits(session, repo_id, limit=20) |
| 609 | return { |
| 610 | "repo_id": repo_id, |
| 611 | "total": commits_result.total, |
| 612 | "commits": [ |
| 613 | { |
| 614 | "commit_id": c.commit_id, |
| 615 | "branch": c.branch, |
| 616 | "message": c.message, |
| 617 | "author": c.author, |
| 618 | "timestamp": c.timestamp.isoformat(), |
| 619 | } |
| 620 | for c in commits_result.commits |
| 621 | ], |
| 622 | } |
| 623 | |
| 624 | async def _repo_commit(session: AsyncSession, repo_id: str, commit_id: str) -> JSONObject: |
| 625 | from musehub.services import musehub_repository |
| 626 | commit = await musehub_repository.get_commit(session, repo_id, commit_id) |
| 627 | if commit is None: |
| 628 | return _err(f"Commit '{commit_id}' not found.") |
| 629 | return { |
| 630 | "commit_id": commit.commit_id, |
| 631 | "repo_id": repo_id, |
| 632 | "branch": commit.branch, |
| 633 | "message": commit.message, |
| 634 | "author": commit.author, |
| 635 | "parent_ids": list(commit.parent_ids) if commit.parent_ids else [], |
| 636 | "timestamp": commit.timestamp.isoformat(), |
| 637 | } |
| 638 | |
| 639 | async def _repo_tree(session: AsyncSession, repo_id: str, ref: str) -> JSONObject: |
| 640 | from musehub.services import musehub_repository |
| 641 | try: |
| 642 | tree_resp = await musehub_repository.list_tree(session, repo_id, "", "", ref, "") |
| 643 | return { |
| 644 | "repo_id": repo_id, |
| 645 | "ref": ref, |
| 646 | "entries": [ |
| 647 | { |
| 648 | "type": e.type, |
| 649 | "name": e.name, |
| 650 | "path": e.path, |
| 651 | "size_bytes": e.size_bytes, |
| 652 | } |
| 653 | for e in tree_resp.entries |
| 654 | ], |
| 655 | } |
| 656 | except Exception as exc: |
| 657 | return _err(str(exc)) |
| 658 | |
| 659 | async def _repo_blob( |
| 660 | session: AsyncSession, repo_id: str, ref: str, path: str |
| 661 | ) -> JSONObject: |
| 662 | from musehub.services import musehub_repository |
| 663 | import mimetypes as _mt |
| 664 | file_meta = await musehub_repository.get_file_at_ref(session, repo_id, ref, path) |
| 665 | if file_meta is None: |
| 666 | return _err(f"File '{path}' not found in repo '{repo_id}' at ref '{ref}'.") |
| 667 | norm_path = str(file_meta["path"]) |
| 668 | object_id = str(file_meta["object_id"]) |
| 669 | guessed, _ = _mt.guess_type(norm_path) |
| 670 | obj_row = await musehub_repository.get_object_row(session, repo_id, object_id) |
| 671 | return { |
| 672 | "repo_id": repo_id, |
| 673 | "ref": ref, |
| 674 | "path": norm_path, |
| 675 | "object_id": object_id, |
| 676 | "size_bytes": obj_row.size_bytes if obj_row else 0, |
| 677 | "mime_type": guessed or "application/octet-stream", |
| 678 | } |
| 679 | |
| 680 | async def _repo_issues(session: AsyncSession, repo_id: str) -> JSONObject: |
| 681 | from musehub.services import musehub_issues |
| 682 | issues = await musehub_issues.list_issues(session, repo_id, state="open") |
| 683 | return { |
| 684 | "repo_id": repo_id, |
| 685 | "issues": [ |
| 686 | { |
| 687 | "issue_id": i.issue_id, |
| 688 | "number": i.number, |
| 689 | "title": i.title, |
| 690 | "state": i.state, |
| 691 | "labels": list(i.labels), |
| 692 | "author": i.author, |
| 693 | } |
| 694 | for i in issues |
| 695 | ], |
| 696 | } |
| 697 | |
| 698 | async def _repo_issue(session: AsyncSession, repo_id: str, number: int) -> JSONObject: |
| 699 | from musehub.services import musehub_issues |
| 700 | issue = await musehub_issues.get_issue(session, repo_id, number) |
| 701 | if issue is None: |
| 702 | return _err(f"Issue #{number} not found.") |
| 703 | comments_resp = await musehub_issues.list_comments(session, issue.issue_id) |
| 704 | return { |
| 705 | "issue_id": issue.issue_id, |
| 706 | "number": issue.number, |
| 707 | "title": issue.title, |
| 708 | "body": issue.body, |
| 709 | "state": issue.state, |
| 710 | "labels": list(issue.labels), |
| 711 | "author": issue.author, |
| 712 | "assignee": issue.assignee, |
| 713 | "created_at": issue.created_at.isoformat() if issue.created_at else None, |
| 714 | "comments": [ |
| 715 | { |
| 716 | "comment_id": c.comment_id, |
| 717 | "author": c.author, |
| 718 | "body": c.body, |
| 719 | "created_at": c.created_at.isoformat() if c.created_at else None, |
| 720 | } |
| 721 | for c in comments_resp.comments |
| 722 | ], |
| 723 | } |
| 724 | |
| 725 | async def _repo_pulls(session: AsyncSession, repo_id: str) -> JSONObject: |
| 726 | from musehub.services import musehub_proposals |
| 727 | proposals = await musehub_proposals.list_proposals(session, repo_id, state="all") |
| 728 | return { |
| 729 | "repo_id": repo_id, |
| 730 | "pulls": [ |
| 731 | { |
| 732 | "proposal_id": p.proposal_id, |
| 733 | "title": p.title, |
| 734 | "state": p.state, |
| 735 | "from_branch": p.from_branch, |
| 736 | "to_branch": p.to_branch, |
| 737 | "author": p.author, |
| 738 | } |
| 739 | for p in proposals |
| 740 | ], |
| 741 | } |
| 742 | |
| 743 | async def _repo_pull(session: AsyncSession, repo_id: str, proposal_id: str) -> JSONObject: |
| 744 | from musehub.services import musehub_proposals |
| 745 | proposal = await musehub_proposals.get_proposal(session, repo_id, proposal_id) |
| 746 | if proposal is None: |
| 747 | return _err(f"Proposal '{proposal_id}' not found.") |
| 748 | comments_resp = await musehub_proposals.list_proposal_comments(session, proposal_id, repo_id) |
| 749 | reviews_resp = await musehub_proposals.list_reviews(session, repo_id=repo_id, proposal_id=proposal_id) |
| 750 | return { |
| 751 | "proposal_id": proposal.proposal_id, |
| 752 | "repo_id": repo_id, |
| 753 | "title": proposal.title, |
| 754 | "body": proposal.body, |
| 755 | "state": proposal.state, |
| 756 | "from_branch": proposal.from_branch, |
| 757 | "to_branch": proposal.to_branch, |
| 758 | "author": proposal.author, |
| 759 | "merge_commit_id": proposal.merge_commit_id, |
| 760 | "created_at": proposal.created_at.isoformat() if proposal.created_at else None, |
| 761 | "merged_at": proposal.merged_at.isoformat() if proposal.merged_at else None, |
| 762 | "comments": [ |
| 763 | { |
| 764 | "comment_id": c.comment_id, |
| 765 | "author": c.author, |
| 766 | "body": c.body, |
| 767 | "dimension_ref": getattr(c, "dimension_ref", {}), |
| 768 | } |
| 769 | for c in comments_resp.comments |
| 770 | ], |
| 771 | "reviews": [ |
| 772 | { |
| 773 | "review_id": r.id, |
| 774 | "reviewer": r.reviewer_username, |
| 775 | "state": r.state, |
| 776 | "body": r.body, |
| 777 | } |
| 778 | for r in reviews_resp.reviews |
| 779 | ], |
| 780 | } |
| 781 | |
| 782 | async def _repo_releases(session: AsyncSession, repo_id: str) -> JSONObject: |
| 783 | from musehub.services import musehub_releases |
| 784 | releases = await musehub_releases.list_releases(session, repo_id) |
| 785 | return { |
| 786 | "repo_id": repo_id, |
| 787 | "releases": [ |
| 788 | { |
| 789 | "release_id": r.release_id, |
| 790 | "tag": r.tag, |
| 791 | "title": r.title, |
| 792 | "is_prerelease": r.is_prerelease, |
| 793 | "created_at": r.created_at.isoformat() if r.created_at else None, |
| 794 | } |
| 795 | for r in releases |
| 796 | ], |
| 797 | } |
| 798 | |
| 799 | async def _repo_release_by_tag(session: AsyncSession, repo_id: str, tag: str) -> JSONObject: |
| 800 | from musehub.services import musehub_releases |
| 801 | release = await musehub_releases.get_release_by_tag(session, repo_id, tag) |
| 802 | if release is None: |
| 803 | return _err(f"Release '{tag}' not found.") |
| 804 | return { |
| 805 | "release_id": release.release_id, |
| 806 | "repo_id": repo_id, |
| 807 | "tag": release.tag, |
| 808 | "title": release.title, |
| 809 | "body": release.body, |
| 810 | "commit_id": release.commit_id, |
| 811 | "is_prerelease": release.is_prerelease, |
| 812 | "author": release.author, |
| 813 | "created_at": release.created_at.isoformat() if release.created_at else None, |
| 814 | } |
| 815 | |
| 816 | async def _repo_insights(session: AsyncSession, repo_id: str, ref: str) -> JSONObject: |
| 817 | from musehub.services import musehub_mcp_executor |
| 818 | result = await musehub_mcp_executor.execute_get_analysis(repo_id, dimension="overview") |
| 819 | if not result.ok: |
| 820 | return _err(result.error_message or "Insights unavailable") |
| 821 | return {"repo_id": repo_id, "ref": ref, "insights": result.data} |
| 822 | |
| 823 | async def _repo_timeline(session: AsyncSession, repo_id: str) -> JSONObject: |
| 824 | from musehub.services import musehub_repository |
| 825 | timeline = await musehub_repository.get_timeline_events(session, repo_id) |
| 826 | return { |
| 827 | "repo_id": repo_id, |
| 828 | "total_commits": timeline.total_commits, |
| 829 | "commits": [ |
| 830 | { |
| 831 | "commit_id": c.commit_id, |
| 832 | "branch": c.branch, |
| 833 | "message": c.message, |
| 834 | "author": c.author, |
| 835 | "timestamp": c.timestamp.isoformat(), |
| 836 | } |
| 837 | for c in timeline.commits |
| 838 | ], |
| 839 | "sections": [ |
| 840 | { |
| 841 | "section_name": s.section_name, |
| 842 | "action": s.action, |
| 843 | "timestamp": s.timestamp.isoformat(), |
| 844 | "commit_id": s.commit_id, |
| 845 | } |
| 846 | for s in timeline.sections |
| 847 | ], |
| 848 | "tracks": [ |
| 849 | { |
| 850 | "track_name": t.track_name, |
| 851 | "action": t.action, |
| 852 | "timestamp": t.timestamp.isoformat(), |
| 853 | "commit_id": t.commit_id, |
| 854 | } |
| 855 | for t in timeline.tracks |
| 856 | ], |
| 857 | } |
| 858 | |
| 859 | _MUSE_DOCS = { |
| 860 | "overview": { |
| 861 | "title": "Muse Paradigm Overview", |
| 862 | "content": ( |
| 863 | "Muse is a domain-agnostic version control system for multidimensional state. " |
| 864 | "Unlike Git, which versions text, Muse can version any state space — " |
| 865 | "MIDI (21 dimensions), code (symbol graph), genomics, climate simulations.\n\n" |
| 866 | "Core concepts:\n" |
| 867 | "- State: A complete snapshot of a multidimensional space at a point in time.\n" |
| 868 | "- Commit: An immutable, content-addressed delta between two states.\n" |
| 869 | "- Branch: A named pointer to a commit, enabling parallel exploration.\n" |
| 870 | "- Merge: Three-way merge using domain-supplied strategies (OT or CRDT).\n" |
| 871 | "- Drift: The divergence metric between two branches — measures how far apart.\n" |
| 872 | "- Domain: A plugin that defines how Muse versions a specific state space.\n\n" |
| 873 | "The scoped domain ID (@author/slug, e.g. @gabriel/midi) uniquely " |
| 874 | "identifies which plugin a repository uses." |
| 875 | ), |
| 876 | }, |
| 877 | "protocol": { |
| 878 | "title": "MuseDomainPlugin Protocol Specification", |
| 879 | "content": ( |
| 880 | "Every Muse domain plugin implements six interfaces:\n\n" |
| 881 | "1. StateSerializer — serialise/deserialise a state snapshot to bytes.\n" |
| 882 | "2. DiffEngine — compute a structured diff between two snapshots.\n" |
| 883 | "3. MergeStrategy — resolve a three-way merge (OT or CRDT-based).\n" |
| 884 | "4. InsightProvider — compute named insight dimensions from a snapshot.\n" |
| 885 | "5. ViewRenderer — return viewport data for the primary domain viewer.\n" |
| 886 | "6. ArtifactManager — enumerate and serve downloadable artifacts.\n\n" |
| 887 | "Capabilities manifest schema:\n" |
| 888 | "{\n" |
| 889 | ' "dimensions": [{"name": str, "description": str}],\n' |
| 890 | ' "viewer_type": "piano_roll" | "symbol_graph" | "sequence_viewer" | "generic",\n' |
| 891 | ' "artifact_types": [str], // MIME types\n' |
| 892 | ' "merge_semantics": "ot" | "crdt" | "three_way",\n' |
| 893 | ' "supported_commands": [str]\n' |
| 894 | "}" |
| 895 | ), |
| 896 | }, |
| 897 | "domains": { |
| 898 | "title": "Domain Plugin Authoring Guide", |
| 899 | "content": ( |
| 900 | "To create a new Muse domain plugin:\n\n" |
| 901 | "1. Implement the six MuseDomainPlugin interfaces.\n" |
| 902 | "2. Define the capabilities manifest (dimensions, viewer_type, etc.).\n" |
| 903 | "3. Register with the MuseHub API: POST /api/domains.\n" |
| 904 | "4. The scoped ID @{your_username}/{plugin_slug} is now globally unique.\n\n" |
| 905 | "Use musehub_publish_domain to register your plugin. " |
| 906 | "Reference @gabriel/midi as the canonical implementation example." |
| 907 | ), |
| 908 | }, |
| 909 | } |
| 910 | |
| 911 | async def _read_muse_resource(path: str) -> JSONObject: |
| 912 | """Handle muse:// URIs: docs, domains, and domain manifests.""" |
| 913 | # muse://docs/{doc} |
| 914 | m = re.match(r"^docs/([a-z_]+)$", path) |
| 915 | if m: |
| 916 | doc_key = m.group(1) |
| 917 | if doc_key in _MUSE_DOCS: |
| 918 | return {"uri": f"muse://docs/{doc_key}", **_MUSE_DOCS[doc_key]} |
| 919 | return _err(f"Unknown doc: muse://docs/{doc_key}") |
| 920 | |
| 921 | # muse://domains (list all) |
| 922 | if path == "domains": |
| 923 | from musehub.services.musehub_mcp_executor import _check_db_available |
| 924 | from musehub.db.database import AsyncSessionLocal |
| 925 | from musehub.services import musehub_domains as _domains_svc |
| 926 | |
| 927 | if _check_db_available() is not None: |
| 928 | return _err("Database unavailable") |
| 929 | |
| 930 | try: |
| 931 | async with AsyncSessionLocal() as session: |
| 932 | result = await _domains_svc.list_domains(session, limit=100) |
| 933 | return { |
| 934 | "domains": [ |
| 935 | { |
| 936 | "domain_id": d.domain_id, |
| 937 | "scoped_id": d.scoped_id, |
| 938 | "display_name": d.display_name, |
| 939 | "description": d.description, |
| 940 | "viewer_type": d.viewer_type, |
| 941 | "dimension_count": len(d.capabilities.get("dimensions", [])), |
| 942 | "install_count": d.install_count, |
| 943 | "is_verified": d.is_verified, |
| 944 | "manifest_hash": d.manifest_hash, |
| 945 | } |
| 946 | for d in result.domains |
| 947 | ], |
| 948 | "total": result.total, |
| 949 | } |
| 950 | except Exception as exc: |
| 951 | logger.exception("muse://domains resource failed: %s", exc) |
| 952 | return _err(str(exc)) |
| 953 | |
| 954 | # muse://domains/{author}/{slug} |
| 955 | m2 = re.match(r"^domains/([^/]+)/([^/]+)$", path) |
| 956 | if m2: |
| 957 | author_slug, slug = m2.group(1), m2.group(2) |
| 958 | from musehub.services.musehub_mcp_executor import _check_db_available |
| 959 | from musehub.db.database import AsyncSessionLocal |
| 960 | from musehub.services import musehub_domains as _domains_svc |
| 961 | |
| 962 | if _check_db_available() is not None: |
| 963 | return _err("Database unavailable") |
| 964 | |
| 965 | try: |
| 966 | async with AsyncSessionLocal() as session: |
| 967 | domain = await _domains_svc.get_domain_by_scoped_id(session, author_slug, slug) |
| 968 | if domain is None: |
| 969 | return _err(f"Domain '@{author_slug}/{slug}' not found.") |
| 970 | return { |
| 971 | "domain_id": domain.domain_id, |
| 972 | "scoped_id": domain.scoped_id, |
| 973 | "display_name": domain.display_name, |
| 974 | "description": domain.description, |
| 975 | "version": domain.version, |
| 976 | "manifest_hash": domain.manifest_hash, |
| 977 | "capabilities": domain.capabilities, |
| 978 | "viewer_type": domain.viewer_type, |
| 979 | "install_count": domain.install_count, |
| 980 | "is_verified": domain.is_verified, |
| 981 | "install_command": f"muse domain install {domain.scoped_id}", |
| 982 | "create_repo_example": { |
| 983 | "tool": "musehub_create_repo", |
| 984 | "params": { |
| 985 | "name": "my-project", |
| 986 | "owner": "myuser", |
| 987 | "domain": domain.scoped_id, |
| 988 | }, |
| 989 | }, |
| 990 | } |
| 991 | except Exception as exc: |
| 992 | logger.exception("muse://domains resource failed (%s/%s): %s", author_slug, slug, exc) |
| 993 | return _err(str(exc)) |
| 994 | |
| 995 | return _err(f"Unknown muse:// resource: muse://{path}") |
| 996 | |
| 997 | async def _read_user(username: str) -> JSONObject: |
| 998 | from musehub.services.musehub_mcp_executor import _check_db_available |
| 999 | from musehub.db.database import AsyncSessionLocal |
| 1000 | from musehub.services import musehub_repository |
| 1001 | |
| 1002 | if _check_db_available() is not None: |
| 1003 | return _err("Database unavailable") |
| 1004 | |
| 1005 | try: |
| 1006 | async with AsyncSessionLocal() as session: |
| 1007 | repo_list = await musehub_repository.list_repos_for_user(session, username, limit=20) |
| 1008 | return { |
| 1009 | "username": username, |
| 1010 | "repos": [ |
| 1011 | { |
| 1012 | "repo_id": r.repo_id, |
| 1013 | "slug": r.slug, |
| 1014 | "name": r.name, |
| 1015 | "visibility": r.visibility, |
| 1016 | } |
| 1017 | for r in repo_list.repos |
| 1018 | if r.visibility == "public" |
| 1019 | ], |
| 1020 | } |
| 1021 | except Exception as exc: |
| 1022 | logger.exception("user resource failed (%s): %s", username, exc) |
| 1023 | return _err(str(exc)) |
| 1024 | |
| 1025 | async def _read_mist(owner: str, mist_id: str, user_id: str | None) -> JSONObject: |
| 1026 | """Return full metadata + content for a single Mist. |
| 1027 | |
| 1028 | Public mists are readable by anyone; secret mists require ``user_id`` |
| 1029 | to match the mist owner. |
| 1030 | |
| 1031 | Args: |
| 1032 | owner: Expected mist owner handle (used for URL construction). |
| 1033 | mist_id: 12-character content-addressed mist ID. |
| 1034 | user_id: Authenticated caller's MSign handle, or ``None``. |
| 1035 | |
| 1036 | Returns: |
| 1037 | JSON dict with full mist data, or ``{"error": "..."}`` on failure. |
| 1038 | """ |
| 1039 | from musehub.services.musehub_mcp_executor import _check_db_available |
| 1040 | from musehub.db.database import AsyncSessionLocal |
| 1041 | from musehub.services import musehub_mists |
| 1042 | |
| 1043 | if _check_db_available() is not None: |
| 1044 | return _err("Database unavailable") |
| 1045 | |
| 1046 | try: |
| 1047 | async with AsyncSessionLocal() as session: |
| 1048 | mist = await musehub_mists.get_mist(session, mist_id) |
| 1049 | if mist is None: |
| 1050 | return _err(f"Mist '{mist_id}' not found.") |
| 1051 | if mist.visibility != "public" and mist.owner != user_id: |
| 1052 | return _err("Secret mist — only the owner may read it.") |
| 1053 | |
| 1054 | await musehub_mists.increment_mist_view(session, mist_id) |
| 1055 | await session.commit() |
| 1056 | |
| 1057 | return { |
| 1058 | "mist_id": mist.mist_id, |
| 1059 | "owner": mist.owner, |
| 1060 | "artifact_type": mist.artifact_type, |
| 1061 | "language": mist.language, |
| 1062 | "filename": mist.filename, |
| 1063 | "title": mist.title, |
| 1064 | "description": mist.description, |
| 1065 | "content": mist.content, |
| 1066 | "size_bytes": mist.size_bytes, |
| 1067 | "version": mist.version, |
| 1068 | "signed": mist.signed, |
| 1069 | "agent_id": mist.agent_id, |
| 1070 | "model_id": mist.model_id, |
| 1071 | "fork_parent_id": mist.fork_parent_id, |
| 1072 | "fork_depth": mist.fork_depth, |
| 1073 | "fork_count": mist.fork_count, |
| 1074 | "view_count": mist.view_count, |
| 1075 | "embed_count": mist.embed_count, |
| 1076 | "visibility": mist.visibility, |
| 1077 | "tags": list(mist.tags), |
| 1078 | "symbol_anchors": list(mist.symbol_anchors), |
| 1079 | "created_at": mist.created_at.isoformat() if mist.created_at else None, |
| 1080 | "updated_at": mist.updated_at.isoformat() if mist.updated_at else None, |
| 1081 | } |
| 1082 | except Exception as exc: |
| 1083 | logger.exception("mist resource failed (%s/%s): %s", owner, mist_id, exc) |
| 1084 | return _err(str(exc)) |
| 1085 | |
| 1086 | async def _read_owner_mists(owner: str, user_id: str | None) -> JSONObject: |
| 1087 | """Return the public Mist list for a given owner. |
| 1088 | |
| 1089 | Secret mists are included only when ``user_id == owner``. |
| 1090 | |
| 1091 | Args: |
| 1092 | owner: MSign handle whose mists to list. |
| 1093 | user_id: Authenticated caller's MSign handle, or ``None``. |
| 1094 | |
| 1095 | Returns: |
| 1096 | JSON dict with ``owner``, ``total``, and ``mists`` list. |
| 1097 | """ |
| 1098 | from musehub.services.musehub_mcp_executor import _check_db_available |
| 1099 | from musehub.db.database import AsyncSessionLocal |
| 1100 | from musehub.services import musehub_mists |
| 1101 | |
| 1102 | if _check_db_available() is not None: |
| 1103 | return _err("Database unavailable") |
| 1104 | |
| 1105 | try: |
| 1106 | include_secret = user_id == owner |
| 1107 | async with AsyncSessionLocal() as session: |
| 1108 | result = await musehub_mists.list_mists( |
| 1109 | session, |
| 1110 | owner=owner, |
| 1111 | include_secret=include_secret, |
| 1112 | limit=50, |
| 1113 | ) |
| 1114 | return { |
| 1115 | "owner": owner, |
| 1116 | "total": result.total, |
| 1117 | "mists": [ |
| 1118 | { |
| 1119 | "mist_id": m.mist_id, |
| 1120 | "artifact_type": m.artifact_type, |
| 1121 | "language": m.language, |
| 1122 | "filename": m.filename, |
| 1123 | "title": m.title, |
| 1124 | "visibility": m.visibility, |
| 1125 | "fork_count": m.fork_count, |
| 1126 | "view_count": m.view_count, |
| 1127 | "tags": list(m.tags), |
| 1128 | "created_at": m.created_at.isoformat() if m.created_at else None, |
| 1129 | } |
| 1130 | for m in result.mists |
| 1131 | ], |
| 1132 | } |
| 1133 | except Exception as exc: |
| 1134 | logger.exception("owner mists resource failed (%s): %s", owner, exc) |
| 1135 | return _err(str(exc)) |
File History
1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠
1 day ago