gabriel / musehub public
resources.py python
1,135 lines 44.9 KB
Raw
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