gabriel / musehub public
musehub_mcp_executor.py python
4,301 lines 160.3 KB
Raw
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 days ago
1 """MuseHub MCP tool executor — server-side logic for all musehub_* MCP tools.
2
3 This module is the execution backend for MuseHub browsing tools exposed via
4 MCP. Each public function opens its own DB session via ``AsyncSessionLocal``,
5 delegates to ``musehub_repository`` for persistence access, and returns a
6 typed ``MusehubToolResult``.
7
8 Design contract
9 ---------------
10 - All functions are async and return ``MusehubToolResult`` on both success
11 and failure (no exceptions propagate to the MCP server).
12 - ``MusehubToolResult.ok`` distinguishes success from failure.
13 - ``MusehubToolResult.error_code`` is one of the ``MusehubErrorCode`` literals
14 (resource-specific not-found codes, input validation, state, auth, infra).
15 - Callers (``MuseMCPServer._execute_musehub_tool``) pattern-match on
16 these codes to build appropriate ``MCPContentBlock`` responses.
17 - This module must NOT import MCP protocol types — it is pure service layer.
18
19 ``AsyncSessionLocal`` is imported at module level so tests can patch it as
20 ``musehub.services.musehub_mcp_executor.AsyncSessionLocal``.
21 """
22
23 import logging
24 import mimetypes
25 from dataclasses import dataclass, field
26 from pathlib import Path
27 from typing import Literal, cast
28
29 from musehub.types.json_types import IntDict, JSONObject, JSONValue, StrDict
30 from musehub.db.musehub_collaborator_models import MusehubCollaborator
31 from musehub.db.musehub_identity_models import MusehubIdentity
32 from musehub.db.musehub_release_models import MusehubRelease
33 from musehub.db.musehub_repo_models import (
34 MusehubBranch,
35 MusehubCommit,
36 MusehubCommitRef,
37 MusehubObject,
38 MusehubObjectRef,
39 MusehubRepo,
40 MusehubSnapshot,
41 )
42 from musehub.db.musehub_social_models import MusehubProposal
43 from musehub.db.database import AsyncSessionLocal
44 from musehub.models.musehub import ProposalResponse
45 from sqlalchemy.ext.asyncio import AsyncSession
46 from musehub.services import musehub_repository
47
48 _MusehubSnapshot = MusehubSnapshot
49
50 logger = logging.getLogger(__name__)
51
52 # ---------------------------------------------------------------------------
53 # Result types
54 # ---------------------------------------------------------------------------
55
56 MusehubErrorCode = Literal[
57 # ── Resource not found (one code per resource type) ───────────────────────
58 "repo_not_found",
59 "branch_not_found",
60 "commit_not_found",
61 "issue_not_found",
62 "proposal_not_found",
63 "release_not_found",
64 "symbol_not_found",
65 "file_not_found",
66 "domain_not_found",
67 "label_not_found",
68 "task_not_found",
69 # ── Input / validation ────────────────────────────────────────────────────
70 "missing_args", # required argument absent
71 "invalid_args", # argument present but malformed or invalid value
72 # ── State ─────────────────────────────────────────────────────────────────
73 "already_exists", # resource already exists (duplicate)
74 "merge_conflict", # merge/rebase conflict
75 "non_fast_forward", # push rejected, not a fast-forward
76 "not_ready", # operation requires a pre-condition not yet met (e.g. index not built)
77 # ── Auth ──────────────────────────────────────────────────────────────────
78 "unauthenticated", # no identity provided
79 "forbidden", # identity present but lacks permission
80 # ── Infrastructure ────────────────────────────────────────────────────────
81 "db_unavailable",
82 "quota_exceeded",
83 # ── VCS transport ─────────────────────────────────────────────────────────
84 "push_failed",
85 "pull_failed",
86 # ── Domain publishing ─────────────────────────────────────────────────────
87 "domain_conflict",
88 "publish_failed",
89 # ── Elicitation flow ──────────────────────────────────────────────────────
90 "elicitation_unavailable",
91 "elicitation_declined",
92 "not_confirmed",
93 # ── Misc ──────────────────────────────────────────────────────────────────
94 "deprecated",
95 ]
96 """Enumeration of error codes returned by MuseHub MCP executors.
97
98 Callers pattern-match on these to build appropriate error messages:
99 - ``repo_not_found`` / ``branch_not_found`` / etc. — specific resource not found
100 - ``missing_args`` — required argument absent
101 - ``invalid_args`` — argument present but malformed or invalid value
102 - ``already_exists`` — resource already exists (duplicate)
103 - ``merge_conflict`` — merge/rebase conflict
104 - ``non_fast_forward`` — push rejected, not a fast-forward
105 - ``not_ready`` — pre-condition not met (e.g. index not built)
106 - ``unauthenticated`` — no identity provided
107 - ``forbidden`` — identity present but lacks permission
108 - ``db_unavailable`` — DB session factory not initialised (startup race)
109 - ``elicitation_unavailable`` — client has no active session / no elicitation capability
110 - ``elicitation_declined`` — user declined or cancelled the elicitation form/URL flow
111 - ``not_confirmed`` — user did not confirm a required confirmation prompt
112 - ``deprecated`` — operation is no longer supported
113 """
114
115
116 @dataclass(frozen=True, slots=True)
117 class MusehubToolResult:
118 """Result of executing a single musehub_* MCP tool.
119
120 ``ok`` is the primary success/failure signal. On success, ``data``
121 holds the JSON-serialisable payload for the MCP content block. On
122 failure, ``error_code`` and ``error_message`` describe what went wrong.
123
124 This type is the contract between the executor functions and the MCP
125 server's routing layer — do not bypass it with raw exceptions.
126 """
127
128 ok: bool
129 data: JSONObject = field(default_factory=dict)
130 error_code: MusehubErrorCode | None = None
131 error_message: str | None = None
132 hint: str | None = None
133
134
135 # ---------------------------------------------------------------------------
136 # Internal helpers
137 # ---------------------------------------------------------------------------
138
139
140 def _check_db_available() -> MusehubToolResult | None:
141 """Return a ``db_unavailable`` error if the session factory is not ready.
142
143 The MCP stdio server runs outside the FastAPI lifespan, so ``init_db()``
144 may not have been called yet. Call this at the top of every executor that
145 opens a DB session so the caller receives a structured error instead of an
146 unhandled ``RuntimeError``.
147 """
148 from musehub.db import database # local import to avoid circular reference
149
150 if database._async_session_factory is None:
151 return MusehubToolResult(
152 ok=False,
153 error_code="db_unavailable",
154 error_message=(
155 "Database session factory is not initialised. "
156 "Ensure DATABASE_URL is set and the service has started up."
157 ),
158 )
159 return None
160
161
162 _EXTRA_MIME: StrDict = {
163 ".webp": "image/webp",
164 ".mid": "audio/midi",
165 ".midi": "audio/midi",
166 }
167
168
169 def _mime_for_path(path: str) -> str:
170 """Resolve MIME type from a file path extension."""
171 ext = Path(path).suffix.lower()
172 if ext in _EXTRA_MIME:
173 return _EXTRA_MIME[ext]
174 guessed, _ = mimetypes.guess_type(path)
175 return guessed or "application/octet-stream"
176
177
178 # ---------------------------------------------------------------------------
179 # Tool executors
180 # ---------------------------------------------------------------------------
181
182
183 async def execute_browse_repo(repo_id: str) -> MusehubToolResult:
184 """Return repo metadata, branch list, and the 10 most recent commits.
185
186 This is the entry-point tool for orienting an agent before it calls more
187 specific tools. It aggregates three repository queries into one response
188 to minimise round-trips for the common "explore a new repo" case.
189
190 Args:
191 repo_id: ID of the target MuseHub repository.
192
193 Returns:
194 ``MusehubToolResult`` with ``data`` containing ``repo``, ``branches``,
195 and ``recent_commits`` keys, or ``error_code="not_found"`` if the
196 repo does not exist.
197 """
198 if (err := _check_db_available()) is not None:
199 return err
200
201 async with AsyncSessionLocal() as session:
202 repo = await musehub_repository.get_repo(session, repo_id)
203 if repo is None:
204 return MusehubToolResult(
205 ok=False,
206 error_code="repo_not_found",
207 error_message=f"Repository '{repo_id}' not found.",
208 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
209 )
210
211 branches = await musehub_repository.list_branches(session, repo_id)
212 commits_result = await musehub_repository.list_commits(
213 session, repo_id, limit=10
214 )
215
216 data: JSONObject = {
217 "repo": {
218 "repo_id": repo.repo_id,
219 "name": repo.name,
220 "visibility": repo.visibility,
221 "owner_user_id": repo.owner_user_id,
222 "clone_url": repo.clone_url,
223 "created_at": repo.created_at.isoformat(),
224 },
225 "branches": [
226 {
227 "branch_id": b.branch_id,
228 "name": b.name,
229 "head_commit_id": b.head_commit_id,
230 }
231 for b in branches
232 ],
233 "recent_commits": [
234 {
235 "commit_id": c.commit_id,
236 "branch": c.branch,
237 "message": c.message,
238 "author": c.author,
239 "timestamp": c.timestamp.isoformat(),
240 }
241 for c in commits_result.commits
242 ],
243 "total_commits": commits_result.total,
244 "branch_count": len(branches),
245 }
246 return MusehubToolResult(ok=True, data=data)
247
248
249 async def execute_list_branches(repo_id: str) -> MusehubToolResult:
250 """Return all branches for a MuseHub repository.
251
252 Agents call this before ``execute_list_commits`` to discover available
253 branch names and their current head commit IDs.
254
255 Args:
256 repo_id: ID of the target MuseHub repository.
257
258 Returns:
259 ``MusehubToolResult`` with ``data.branches`` as a list of branch
260 dicts, or ``error_code="not_found"`` if the repo does not exist.
261 """
262 if (err := _check_db_available()) is not None:
263 return err
264
265 async with AsyncSessionLocal() as session:
266 repo = await musehub_repository.get_repo(session, repo_id)
267 if repo is None:
268 return MusehubToolResult(
269 ok=False,
270 error_code="repo_not_found",
271 error_message=f"Repository '{repo_id}' not found.",
272 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
273 )
274
275 branches = await musehub_repository.list_branches(session, repo_id)
276 data: JSONObject = {
277 "repo_id": repo_id,
278 "branches": [
279 {
280 "branch_id": b.branch_id,
281 "name": b.name,
282 "head_commit_id": b.head_commit_id,
283 }
284 for b in branches
285 ],
286 "branch_count": len(branches),
287 }
288 return MusehubToolResult(ok=True, data=data)
289
290
291 async def execute_list_commits(
292 repo_id: str,
293 branch: str | None = None,
294 limit: int = 20,
295 ) -> MusehubToolResult:
296 """Return paginated commits for a MuseHub repository, newest first.
297
298 Args:
299 repo_id: ID of the target MuseHub repository.
300 branch: Optional branch name filter; None returns across all branches.
301 limit: Maximum commits to return (clamped to 1–100).
302
303 Returns:
304 ``MusehubToolResult`` with ``data.commits`` and ``data.total``,
305 or ``error_code="not_found"`` if the repo does not exist.
306 """
307 limit = max(1, min(limit, 100))
308
309 if (err := _check_db_available()) is not None:
310 return err
311
312 async with AsyncSessionLocal() as session:
313 repo = await musehub_repository.get_repo(session, repo_id)
314 if repo is None:
315 return MusehubToolResult(
316 ok=False,
317 error_code="repo_not_found",
318 error_message=f"Repository '{repo_id}' not found.",
319 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
320 )
321
322 commits_result = await musehub_repository.list_commits(
323 session, repo_id, branch=branch, limit=limit
324 )
325
326 commit_list: list[JSONValue] = []
327 for c in commits_result.commits:
328 # parent_ids is list[str]; build list[JSONValue] explicitly (list invariance).
329 parent_ids_json: list[JSONValue] = []
330 for pid in c.parent_ids:
331 parent_ids_json.append(pid)
332 commit_list.append({
333 "commit_id": c.commit_id,
334 "branch": c.branch,
335 "parent_ids": parent_ids_json,
336 "message": c.message,
337 "author": c.author,
338 "timestamp": c.timestamp.isoformat(),
339 "snapshot_id": c.snapshot_id,
340 })
341
342 data: JSONObject = {
343 "repo_id": repo_id,
344 "branch_filter": branch,
345 "commits": commit_list,
346 "returned": len(commits_result.commits),
347 "total": commits_result.total,
348 }
349 return MusehubToolResult(ok=True, data=data)
350
351
352 async def execute_read_file(repo_id: str, object_id: str) -> MusehubToolResult:
353 """Return metadata for a stored artifact in a MuseHub repo.
354
355 Returns path, size_bytes, mime_type, and object_id. Binary content is
356 intentionally excluded — MCP tool responses must be text-safe JSON.
357 Agents that need the raw bytes should use the HTTP objects endpoint.
358
359 Args:
360 repo_id: ID of the target MuseHub repository.
361 object_id: Content-addressed ID (e.g. ``sha256:abc...``).
362
363 Returns:
364 ``MusehubToolResult`` with file metadata, or ``error_code="not_found"``
365 if the repo or object does not exist.
366 """
367 if (err := _check_db_available()) is not None:
368 return err
369
370 async with AsyncSessionLocal() as session:
371 repo = await musehub_repository.get_repo(session, repo_id)
372 if repo is None:
373 return MusehubToolResult(
374 ok=False,
375 error_code="repo_not_found",
376 error_message=f"Repository '{repo_id}' not found.",
377 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
378 )
379
380 obj = await musehub_repository.get_object_row(session, repo_id, object_id)
381 if obj is None:
382 return MusehubToolResult(
383 ok=False,
384 error_code="file_not_found",
385 error_message=f"Object '{object_id}' not found in repository '{repo_id}'.",
386 )
387
388 data: JSONObject = {
389 "object_id": obj.object_id,
390 "repo_id": repo_id,
391 "path": obj.path,
392 "size_bytes": obj.size_bytes,
393 "mime_type": _mime_for_path(obj.path),
394 "created_at": obj.created_at.isoformat(),
395 }
396 return MusehubToolResult(ok=True, data=data)
397
398
399 async def execute_get_analysis(
400 repo_id: str,
401 dimension: str = "overview",
402 ) -> MusehubToolResult:
403 """Return structured analysis for a MuseHub repository.
404
405 Supported dimensions:
406 - ``overview`` — repo stats: branch count, commit count, blob count,
407 most active author, most recent commit timestamp.
408 - ``commits`` — commit activity: total, per-branch breakdown, author
409 distribution, and a sample of the most recent messages.
410 - ``blobs`` — artifact inventory: total size, per-MIME-type counts
411 and sizes, and a sample of blob paths.
412
413 MIDI audio analysis (key, tempo, harmonic content) requires Storpheus
414 integration and is not yet available; those fields will be ``null``.
415
416 Args:
417 repo_id: ID of the target MuseHub repository.
418 dimension: Analysis dimension — one of ``overview``, ``commits``,
419 ``blobs``.
420
421 Returns:
422 ``MusehubToolResult`` with analysis data, or an error code on failure.
423 """
424 valid_dimensions = {"overview", "commits", "blobs"}
425 if dimension not in valid_dimensions:
426 return MusehubToolResult(
427 ok=False,
428 error_code="invalid_args",
429 error_message=(
430 f"Unknown dimension '{dimension}'. "
431 f"Valid values: {', '.join(sorted(valid_dimensions))}."
432 ),
433 )
434
435 if (err := _check_db_available()) is not None:
436 return err
437
438 async with AsyncSessionLocal() as session:
439 repo = await musehub_repository.get_repo(session, repo_id)
440 if repo is None:
441 return MusehubToolResult(
442 ok=False,
443 error_code="repo_not_found",
444 error_message=f"Repository '{repo_id}' not found.",
445 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
446 )
447
448 if dimension == "overview":
449 branches = await musehub_repository.list_branches(session, repo_id)
450 commits_result = await musehub_repository.list_commits(
451 session, repo_id, limit=1
452 )
453 objects = await musehub_repository.list_objects(session, repo_id)
454
455 last_commit_at: JSONValue = None
456 most_recent_author: JSONValue = None
457 if commits_result.commits:
458 last_commit_at = commits_result.commits[0].timestamp.isoformat()
459 most_recent_author = commits_result.commits[0].author
460
461 data: JSONObject = {
462 "repo_id": repo_id,
463 "dimension": "overview",
464 "repo_name": repo.name,
465 "visibility": repo.visibility,
466 "branch_count": len(branches),
467 "commit_count": commits_result.total,
468 "object_count": len(objects),
469 "last_commit_at": last_commit_at,
470 "most_recent_author": most_recent_author,
471 }
472 return MusehubToolResult(ok=True, data=data)
473
474 if dimension == "commits":
475 all_commits_result = await musehub_repository.list_commits(
476 session, repo_id, limit=100
477 )
478
479 by_branch: IntDict = {}
480 by_author: IntDict = {}
481 for c in all_commits_result.commits:
482 by_branch[c.branch] = by_branch.get(c.branch, 0) + 1
483 by_author[c.author] = by_author.get(c.author, 0) + 1
484
485 data = {
486 "repo_id": repo_id,
487 "dimension": "commits",
488 "total_commits": all_commits_result.total,
489 "commits_in_sample": len(all_commits_result.commits),
490 "by_branch": {k: v for k, v in by_branch.items()},
491 "by_author": {k: v for k, v in by_author.items()},
492 "recent_messages": [c.message for c in all_commits_result.commits[:5]],
493 }
494 return MusehubToolResult(ok=True, data=data)
495
496 # dimension == "blobs"
497 blobs = await musehub_repository.list_objects(session, repo_id)
498
499 by_mime: IntDict = {}
500 size_by_mime: IntDict = {}
501 total_size = 0
502 for obj in blobs:
503 mime = _mime_for_path(obj.path)
504 by_mime[mime] = by_mime.get(mime, 0) + 1
505 size_by_mime[mime] = size_by_mime.get(mime, 0) + obj.size_bytes
506 total_size += obj.size_bytes
507
508 data = {
509 "repo_id": repo_id,
510 "dimension": "blobs",
511 "total_blobs": len(blobs),
512 "total_size_bytes": total_size,
513 "by_mime_type": {k: v for k, v in by_mime.items()},
514 "size_by_mime_type": {k: v for k, v in size_by_mime.items()},
515 "sample_paths": [obj.path for obj in blobs[:10]],
516 }
517 return MusehubToolResult(ok=True, data=data)
518
519
520 async def execute_search(
521 repo_id: str,
522 query: str,
523 mode: str = "path",
524 ) -> MusehubToolResult:
525 """Search within a MuseHub repository by substring match.
526
527 Search is case-insensitive substring matching. Two modes are supported:
528 - ``path`` — matches object file paths (e.g. ``tracks/jazz_4b.mid``).
529 - ``commit`` — matches commit messages (e.g. ``add bass intro``).
530
531 The search operates on the full in-memory dataset (no DB-level LIKE query)
532 so results are consistent across database backends. For very large repos
533 (>10 k objects/commits) this may be slow — index-backed search is a
534 planned future enhancement.
535
536 Args:
537 repo_id: ID of the target MuseHub repository.
538 query: Case-insensitive substring to search for.
539 mode: ``"path"`` or ``"commit"``.
540
541 Returns:
542 ``MusehubToolResult`` with ``data.results`` list, or an error on failure.
543 """
544 valid_modes = {"path", "commit"}
545 if mode not in valid_modes:
546 return MusehubToolResult(
547 ok=False,
548 error_code="invalid_args",
549 error_message=(
550 f"Unknown search mode '{mode}'. "
551 f"Valid values: {', '.join(sorted(valid_modes))}."
552 ),
553 )
554
555 if (err := _check_db_available()) is not None:
556 return err
557
558 q = query.lower()
559
560 async with AsyncSessionLocal() as session:
561 repo = await musehub_repository.get_repo(session, repo_id)
562 if repo is None:
563 return MusehubToolResult(
564 ok=False,
565 error_code="repo_not_found",
566 error_message=f"Repository '{repo_id}' not found.",
567 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
568 )
569
570 if mode == "path":
571 objects = await musehub_repository.list_objects(session, repo_id)
572 results: list[JSONValue] = [
573 {
574 "object_id": obj.object_id,
575 "path": obj.path,
576 "size_bytes": obj.size_bytes,
577 "mime_type": _mime_for_path(obj.path),
578 }
579 for obj in objects
580 if q in obj.path.lower()
581 ]
582 else: # mode == "commit"
583 search_commits_result = await musehub_repository.list_commits(
584 session, repo_id, limit=100
585 )
586 results = [
587 {
588 "commit_id": c.commit_id,
589 "branch": c.branch,
590 "message": c.message,
591 "author": c.author,
592 "timestamp": c.timestamp.isoformat(),
593 }
594 for c in search_commits_result.commits
595 if q in c.message.lower()
596 ]
597
598 data: JSONObject = {
599 "repo_id": repo_id,
600 "query": query,
601 "mode": mode,
602 "result_count": len(results),
603 "results": results,
604 }
605 return MusehubToolResult(ok=True, data=data)
606
607
608 async def execute_read_context(repo_id: str) -> MusehubToolResult:
609 """Return the full AI context document for a MuseHub repository.
610
611 This is the primary read-side interface for music generation agents.
612 It aggregates repo metadata, all branches, the 10 most recent commits
613 across all branches, and the full artifact inventory into a single
614 structured document — ready to paste into an agent's context window.
615
616 Feed this document to the agent before generating new music to ensure
617 harmonic and structural coherence with existing work in the repository.
618
619 Musical analysis fields (key, tempo, time_signature) are ``null`` until
620 Storpheus MIDI analysis integration is complete.
621
622 Args:
623 repo_id: ID of the target MuseHub repository.
624
625 Returns:
626 ``MusehubToolResult`` with ``data.context`` (the full context doc),
627 or ``error_code="not_found"`` if the repo does not exist.
628 """
629 if (err := _check_db_available()) is not None:
630 return err
631
632 async with AsyncSessionLocal() as session:
633 repo = await musehub_repository.get_repo(session, repo_id)
634 if repo is None:
635 return MusehubToolResult(
636 ok=False,
637 error_code="repo_not_found",
638 error_message=f"Repository '{repo_id}' not found.",
639 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
640 )
641
642 branches = await musehub_repository.list_branches(session, repo_id)
643 context_commits_result = await musehub_repository.list_commits(
644 session, repo_id, limit=10
645 )
646
647 # Collect file paths from snapshot manifests rather than musehub_objects.path,
648 # which is always empty because the wire protocol's BlobPayload carries no
649 # path hint. Snapshot manifests store the authoritative {path: object_id} map.
650 snapshot_ids: list[str] = []
651 for c in context_commits_result.commits:
652 if c.snapshot_id:
653 snapshot_ids.append(c.snapshot_id)
654 for b in branches:
655 if b.head_commit_id:
656 head = await musehub_repository.get_commit(session, repo_id, b.head_commit_id)
657 if head and head.snapshot_id and head.snapshot_id not in snapshot_ids:
658 snapshot_ids.append(head.snapshot_id)
659
660 from musehub.services.musehub_snapshot import get_snapshot_manifest
661 seen_paths: set[str] = set()
662 sorted_paths: list[str] = []
663 for snap_id in snapshot_ids:
664 snap_manifest = await get_snapshot_manifest(session, snap_id)
665 for path in snap_manifest:
666 if path and path not in seen_paths:
667 seen_paths.add(path)
668 sorted_paths.append(path)
669 sorted_paths.sort()
670
671 by_mime: IntDict = {}
672 for path in sorted_paths:
673 mime = _mime_for_path(path)
674 by_mime[mime] = by_mime.get(mime, 0) + 1
675
676 all_paths: list[JSONValue] = list(sorted_paths)
677
678 context: JSONObject = {
679 "repo": {
680 "repo_id": repo.repo_id,
681 "name": repo.name,
682 "visibility": repo.visibility,
683 "owner_user_id": repo.owner_user_id,
684 "created_at": repo.created_at.isoformat(),
685 },
686 "branches": [
687 {
688 "name": b.name,
689 "head_commit_id": b.head_commit_id,
690 }
691 for b in branches
692 ],
693 "recent_commits": [
694 {
695 "commit_id": c.commit_id,
696 "branch": c.branch,
697 "message": c.message,
698 "author": c.author,
699 "timestamp": c.timestamp.isoformat(),
700 }
701 for c in context_commits_result.commits
702 ],
703 "commit_stats": {
704 "total": context_commits_result.total,
705 "shown": len(context_commits_result.commits),
706 },
707 "artifacts": {
708 "total_count": len(all_paths),
709 "by_mime_type": {k: v for k, v in by_mime.items()},
710 "paths": all_paths,
711 },
712 "musical_analysis": {
713 "key": None,
714 "tempo": None,
715 "time_signature": None,
716 "note": (
717 "Musical analysis requires Storpheus MIDI integration "
718 "(not yet available — fields will be populated in a future release)."
719 ),
720 },
721 }
722
723 data: JSONObject = {
724 "repo_id": repo_id,
725 "context": context,
726 }
727 return MusehubToolResult(ok=True, data=data)
728
729
730 # ---------------------------------------------------------------------------
731 # New read tool executors (8) — wired by the dispatcher
732 # ---------------------------------------------------------------------------
733
734
735 async def execute_read_commit(repo_id: str, commit_id: str) -> MusehubToolResult:
736 """Return detailed information about a single commit.
737
738 Args:
739 repo_id: ID of the target repository.
740 commit_id: Commit ID (SHA or short ID).
741
742 Returns:
743 ``MusehubToolResult`` with commit metadata and parent IDs.
744 """
745 if (err := _check_db_available()) is not None:
746 return err
747
748 async with AsyncSessionLocal() as session:
749 commit = await musehub_repository.get_commit(session, repo_id, commit_id)
750 if commit is None:
751 return MusehubToolResult(
752 ok=False,
753 error_code="commit_not_found",
754 error_message=f"Commit '{commit_id}' not found in repo '{repo_id}'.",
755 )
756 data: JSONObject = {
757 "commit_id": commit.commit_id,
758 "repo_id": repo_id,
759 "branch": commit.branch,
760 "message": commit.message,
761 "author": commit.author,
762 "parent_ids": list(commit.parent_ids) if commit.parent_ids else [],
763 "timestamp": commit.timestamp.isoformat(),
764 }
765 return MusehubToolResult(ok=True, data=data)
766
767
768 async def execute_compare(
769 repo_id: str,
770 base_ref: str,
771 head_ref: str,
772 ) -> MusehubToolResult:
773 """Compare two refs and return a musical diff summary.
774
775 Args:
776 repo_id: ID of the repository.
777 base_ref: Base branch name or commit ID.
778 head_ref: Head branch name or commit ID to compare against base.
779
780 Returns:
781 ``MusehubToolResult`` with artifact-level diff (added/removed/modified counts).
782 """
783 if (err := _check_db_available()) is not None:
784 return err
785
786 async with AsyncSessionLocal() as session:
787 repo = await musehub_repository.get_repo(session, repo_id)
788 if repo is None:
789 return MusehubToolResult(
790 ok=False,
791 error_code="repo_not_found",
792 error_message=f"Repository '{repo_id}' not found.",
793 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
794 )
795
796 base_result = await musehub_repository.list_commits(session, repo_id, branch=base_ref, limit=1)
797 head_result = await musehub_repository.list_commits(session, repo_id, branch=head_ref, limit=1)
798
799 base_objects = await musehub_repository.list_objects(session, repo_id)
800 head_objects = await musehub_repository.list_objects(session, repo_id)
801
802 data: JSONObject = {
803 "repo_id": repo_id,
804 "base_ref": base_ref,
805 "head_ref": head_ref,
806 "base_commit_id": base_result.commits[0].commit_id if base_result.commits else None,
807 "head_commit_id": head_result.commits[0].commit_id if head_result.commits else None,
808 "note": (
809 "Full musical diff (harmony, rhythm, groove scores) requires "
810 "Storpheus MIDI analysis integration (coming soon). "
811 "Currently returns object inventory for both refs."
812 ),
813 "base_object_count": len(base_objects),
814 "head_object_count": len(head_objects),
815 }
816 return MusehubToolResult(ok=True, data=data)
817
818
819 async def execute_list_issues(
820 repo_id: str,
821 state: str = "open",
822 label: str | None = None,
823 ) -> MusehubToolResult:
824 """List issues for a MuseHub repository.
825
826 Args:
827 repo_id: ID of the repository.
828 state: Filter by state — ``"open"``, ``"closed"``, or ``"all"``.
829 label: Optional label string filter.
830
831 Returns:
832 ``MusehubToolResult`` with ``data.issues`` list.
833 """
834 if (err := _check_db_available()) is not None:
835 return err
836
837 async with AsyncSessionLocal() as session:
838 repo = await musehub_repository.get_repo(session, repo_id)
839 if repo is None:
840 return MusehubToolResult(
841 ok=False,
842 error_code="repo_not_found",
843 error_message=f"Repository '{repo_id}' not found.",
844 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
845 )
846
847 from musehub.services import musehub_issues
848 issues_result = await musehub_issues.list_issues(session, repo_id, state=state, label=label)
849 data: JSONObject = {
850 "repo_id": repo_id,
851 "state": state,
852 "total": issues_result.total,
853 "issues": [
854 {
855 "issue_id": i.issue_id,
856 "number": i.number,
857 "title": i.title,
858 "state": i.state,
859 "labels": list(i.labels),
860 "author": i.author,
861 "assignee": i.assignee,
862 "created_at": i.created_at.isoformat() if i.created_at else None,
863 }
864 for i in issues_result.issues
865 ],
866 }
867 return MusehubToolResult(ok=True, data=data)
868
869
870 async def execute_read_issue(repo_id: str, issue_number: int) -> MusehubToolResult:
871 """Return a single issue with its full comment thread.
872
873 Args:
874 repo_id: ID of the repository.
875 issue_number: Per-repo issue number.
876
877 Returns:
878 ``MusehubToolResult`` with issue and comments data.
879 """
880 if (err := _check_db_available()) is not None:
881 return err
882
883 async with AsyncSessionLocal() as session:
884 from musehub.services import musehub_issues
885 issue = await musehub_issues.get_issue(session, repo_id, issue_number)
886 if issue is None:
887 return MusehubToolResult(
888 ok=False,
889 error_code="issue_not_found",
890 error_message=f"Issue #{issue_number} not found in repo '{repo_id}'.",
891 hint="Call musehub_list_issues() to see open issues.",
892 )
893 comments_resp = await musehub_issues.list_comments(session, issue.issue_id)
894 data: JSONObject = {
895 "issue_id": issue.issue_id,
896 "number": issue.number,
897 "title": issue.title,
898 "body": issue.body,
899 "state": issue.state,
900 "labels": list(issue.labels),
901 "author": issue.author,
902 "assignee": issue.assignee,
903 "created_at": issue.created_at.isoformat() if issue.created_at else None,
904 "comments": [
905 {
906 "comment_id": c.comment_id,
907 "author": c.author,
908 "body": c.body,
909 "created_at": c.created_at.isoformat() if c.created_at else None,
910 }
911 for c in comments_resp.comments
912 ],
913 }
914 return MusehubToolResult(ok=True, data=data)
915
916
917 async def execute_list_proposals(repo_id: str, state: str = "all") -> MusehubToolResult:
918 """List merge proposals for a MuseHub repository.
919
920 Args:
921 repo_id: ID of the repository.
922 state: Filter by state — ``"open"``, ``"merged"``, ``"closed"``, or ``"all"``.
923
924 Returns:
925 ``MusehubToolResult`` with ``data.pulls`` list.
926 """
927 if (err := _check_db_available()) is not None:
928 return err
929
930 async with AsyncSessionLocal() as session:
931 repo = await musehub_repository.get_repo(session, repo_id)
932 if repo is None:
933 return MusehubToolResult(
934 ok=False,
935 error_code="repo_not_found",
936 error_message=f"Repository '{repo_id}' not found.",
937 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
938 )
939 from musehub.services import musehub_proposals
940 proposals_result = await musehub_proposals.list_proposals(session, repo_id, state=state)
941 data: JSONObject = {
942 "repo_id": repo_id,
943 "state": state,
944 "total": proposals_result.total,
945 "pulls": [
946 {
947 "proposal_id": p.proposal_id,
948 "title": p.title,
949 "state": p.state,
950 "from_branch": p.from_branch,
951 "to_branch": p.to_branch,
952 "author": p.author,
953 "created_at": p.created_at.isoformat() if p.created_at else None,
954 "merged_at": p.merged_at.isoformat() if p.merged_at else None,
955 }
956 for p in proposals_result.proposals
957 ],
958 }
959 return MusehubToolResult(ok=True, data=data)
960
961
962 async def execute_list_proposals_context(
963 repo_id: str,
964 state: str = "open",
965 ) -> MusehubToolResult:
966 """Return enriched proposal contexts for the merge queue.
967
968 Combines ``list_proposals`` with ``enrich_proposal_list_batch`` so agents
969 get risk, approval status, domain activity, and blocking status for every
970 proposal in a single call — no per-proposal follow-up needed.
971
972 Args:
973 repo_id: ID of the repository.
974 state: Filter by state — ``"open"``, ``"merged"``, ``"settling"``, or ``"all"``.
975
976 Returns:
977 ``MusehubToolResult`` with ``data.proposals`` list of enriched contexts.
978 """
979 if (err := _check_db_available()) is not None:
980 return err
981
982 async with AsyncSessionLocal() as session:
983 repo = await musehub_repository.get_repo(session, repo_id)
984 if repo is None:
985 return MusehubToolResult(
986 ok=False,
987 error_code="repo_not_found",
988 error_message=f"Repository '{repo_id}' not found.",
989 hint="Call musehub_search_repos() to find available repositories.",
990 )
991 from sqlalchemy import select as _select
992 from musehub.models.musehub_context import EnrichedProposalContext
993 from musehub.services import musehub_proposals as svc
994
995 filters = svc.ProposalListFilters(state=state, limit=50)
996 ids_result = await svc.list_proposals(session, repo_id, filters=filters)
997 proposal_ids = [p.proposal_id for p in ids_result.proposals]
998 orm_rows: list[MusehubProposal] = []
999 if proposal_ids:
1000 orm_rows = list(
1001 (
1002 await session.execute(
1003 _select(MusehubProposal).where(
1004 MusehubProposal.proposal_id.in_(proposal_ids)
1005 )
1006 )
1007 ).scalars()
1008 )
1009 order_map = {pid: i for i, pid in enumerate(proposal_ids)}
1010 orm_rows.sort(key=lambda r: order_map.get(r.proposal_id, 9999))
1011 entries = await svc.enrich_proposal_list_batch(orm_rows, session)
1012
1013 data: JSONObject = {
1014 "repo_id": repo_id,
1015 "state": state,
1016 "total": ids_result.total,
1017 "proposals": [
1018 EnrichedProposalContext(
1019 proposal_id=e.proposal_id,
1020 title=e.title,
1021 from_branch=e.from_branch,
1022 to_branch=e.to_branch,
1023 state=e.state,
1024 body="",
1025 aggregate_risk_band=e.aggregate_risk_band,
1026 aggregate_risk_score=e.aggregate_risk_score,
1027 active_domains=e.active_domains,
1028 domain_risk_band=e.domain_risk_band,
1029 approval_count=e.approval_count,
1030 required_approvals=e.required_approvals,
1031 all_merge_conditions_met=e.all_merge_conditions_met,
1032 is_blocked=e.is_blocked,
1033 blocked_by=e.blocked_by,
1034 author_type=e.author_type,
1035 breakage_count=e.breakage_count,
1036 ).model_dump()
1037 for e in entries
1038 ],
1039 }
1040 return MusehubToolResult(ok=True, data=data)
1041
1042
1043 async def execute_read_proposal(repo_id: str, proposal_id: str) -> MusehubToolResult:
1044 """Return a single merge proposal with reviews and inline comments.
1045
1046 Args:
1047 repo_id: ID of the repository.
1048 proposal_id: ID of the merge proposal.
1049
1050 Returns:
1051 ``MusehubToolResult`` with proposal data, reviews, and comments.
1052 """
1053 if (err := _check_db_available()) is not None:
1054 return err
1055
1056 async with AsyncSessionLocal() as session:
1057 from musehub.services import musehub_proposals
1058 proposal = await musehub_proposals.get_proposal(session, repo_id, proposal_id)
1059 if proposal is None:
1060 return MusehubToolResult(
1061 ok=False,
1062 error_code="proposal_not_found",
1063 error_message=f"Proposal '{proposal_id}' not found in repo '{repo_id}'.",
1064 hint="Call musehub_list_proposals() to see open proposals.",
1065 )
1066 comments_resp = await musehub_proposals.list_proposal_comments(session, proposal_id, repo_id)
1067 reviews_resp = await musehub_proposals.list_reviews(session, repo_id=repo_id, proposal_id=proposal_id)
1068 data: JSONObject = {
1069 "proposal_id": proposal.proposal_id,
1070 "repo_id": repo_id,
1071 "title": proposal.title,
1072 "body": proposal.body,
1073 "state": proposal.state,
1074 "from_branch": proposal.from_branch,
1075 "to_branch": proposal.to_branch,
1076 "author": proposal.author,
1077 "merge_commit_id": proposal.merge_commit_id,
1078 "created_at": proposal.created_at.isoformat() if proposal.created_at else None,
1079 "merged_at": proposal.merged_at.isoformat() if proposal.merged_at else None,
1080 "comments": [
1081 {
1082 "comment_id": c.comment_id,
1083 "author": c.author,
1084 "body": c.body,
1085 "symbol_address": c.symbol_address,
1086 "target_type": c.target_type,
1087 "target_track": c.target_track,
1088 "target_beat_start": c.target_beat_start,
1089 "target_beat_end": c.target_beat_end,
1090 "created_at": c.created_at.isoformat() if c.created_at else None,
1091 }
1092 for c in comments_resp.comments
1093 ],
1094 "reviews": [
1095 {
1096 "review_id": r.id,
1097 "reviewer": r.reviewer_username,
1098 "state": r.state,
1099 "body": r.body,
1100 "submitted_at": r.submitted_at.isoformat() if r.submitted_at else None,
1101 }
1102 for r in reviews_resp.reviews
1103 ],
1104 }
1105 return MusehubToolResult(ok=True, data=data)
1106
1107
1108 async def _extract_proposal_symbol_data(
1109 session: AsyncSession,
1110 repo_id: str,
1111 proposal_id: str,
1112 ) -> tuple[ProposalResponse, list[JSONObject], list[str], list[str], list[str], list[str]] | None:
1113 """Shared helper: load proposal + commits + symbol delta.
1114
1115 Returns (proposal, proposal_commits, sym_added_names, sym_modified_names,
1116 sym_deleted_names, breaking_changes) or None if the proposal is not found.
1117 """
1118 import re as _re
1119 from musehub.services import musehub_proposals
1120 from sqlalchemy import select as _sel
1121
1122 proposal = await musehub_proposals.get_proposal(session, repo_id, proposal_id)
1123 if proposal is None:
1124 return None
1125
1126 commit_rows = (await session.execute(
1127 _sel(MusehubCommit)
1128 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
1129 .where(
1130 MusehubCommitRef.repo_id == repo_id,
1131 MusehubCommit.branch == proposal.from_branch,
1132 )
1133 .order_by(MusehubCommit.created_at.desc())
1134 .limit(25)
1135 )).scalars().all()
1136
1137 _semver_priority: IntDict = {"major": 3, "minor": 2, "patch": 1, "none": 0}
1138 _conv_re = _re.compile(r"^(\w+)(\([^)]*\))?(!)?\s*:")
1139 sym_added_names: list[str] = []
1140 sym_modified_names: list[str] = []
1141 sym_deleted_names: list[str] = []
1142 breaking_changes: list[str] = []
1143 proposal_commits = []
1144
1145 for c in commit_rows:
1146 m = _conv_re.match((c.message or "").strip())
1147 proposal_commits.append({
1148 "commit_id": c.commit_id,
1149 "is_agent": bool(c.agent_id),
1150 "is_signed": bool(c.signature),
1151 "message": c.message,
1152 "commit_type": m.group(1).lower() if m else "",
1153 })
1154 bc = c.breaking_changes
1155 if isinstance(bc, list):
1156 breaking_changes.extend(str(x) for x in bc if x)
1157 structured_delta = c.structured_delta if isinstance(c.structured_delta, dict) else {}
1158 for file_op in (structured_delta.get("ops") or []):
1159 if not isinstance(file_op, dict):
1160 continue
1161 for child_op in (file_op.get("child_ops") or []):
1162 if not isinstance(child_op, dict):
1163 continue
1164 op = str(child_op.get("op", ""))
1165 name = str(
1166 child_op.get("address") or child_op.get("name")
1167 or file_op.get("address") or ""
1168 ).strip()
1169 if op == "insert":
1170 if name and name not in sym_added_names:
1171 sym_added_names.append(name)
1172 elif op == "delete":
1173 if name and name not in sym_deleted_names:
1174 sym_deleted_names.append(name)
1175 elif op in ("patch", "replace"):
1176 if name and name not in sym_modified_names:
1177 sym_modified_names.append(name)
1178
1179 return proposal, proposal_commits, sym_added_names, sym_modified_names, sym_deleted_names, breaking_changes
1180
1181
1182 async def execute_read_proposal_risk(repo_id: str, proposal_id: str) -> MusehubToolResult:
1183 """Return the computed risk score for a merge proposal.
1184
1185 Args:
1186 repo_id: ID of the repository.
1187 proposal_id: ID of the merge proposal.
1188
1189 Returns:
1190 ``MusehubToolResult`` with risk score, band, blast delta, breakage count,
1191 symbol totals, agent commit ratio, test gap count, and signing status.
1192 """
1193 if (err := _check_db_available()) is not None:
1194 return err
1195
1196 async with AsyncSessionLocal() as session:
1197 payload = await _extract_proposal_symbol_data(session, repo_id, proposal_id)
1198 if payload is None:
1199 return MusehubToolResult(
1200 ok=False, error_code="proposal_not_found",
1201 error_message=f"Proposal '{proposal_id}' not found in repo '{repo_id}'.",
1202 hint="Call musehub_list_proposals() to see open proposals.",
1203 )
1204 proposal, proposal_commits, sym_added_names, sym_modified_names, sym_deleted_names, breaking_changes = payload
1205
1206 from musehub.services.musehub_symbol_indexer import load_symbol_history as _load_hist
1207 from musehub.services.musehub_proposal_risk import compute_risk
1208 symbol_history = await _load_hist(session, repo_id)
1209 risk = compute_risk(
1210 breaking_changes=breaking_changes,
1211 sym_added=len(sym_added_names),
1212 sym_modified=len(sym_modified_names),
1213 sym_deleted=len(sym_deleted_names),
1214 sym_modified_names=sym_modified_names,
1215 sym_deleted_names=sym_deleted_names,
1216 proposal_commits=proposal_commits,
1217 symbol_history=symbol_history,
1218 )
1219 return MusehubToolResult(ok=True, data={**risk.as_dict()})
1220
1221
1222 async def execute_read_proposal_diff(repo_id: str, proposal_id: str) -> MusehubToolResult:
1223 """Return the full symbol-level diff for a merge proposal.
1224
1225 Args:
1226 repo_id: ID of the repository.
1227 proposal_id: ID of the merge proposal.
1228
1229 Returns:
1230 ``MusehubToolResult`` with sym_added, sym_modified, sym_deleted counts
1231 and full name lists for each category.
1232 """
1233 if (err := _check_db_available()) is not None:
1234 return err
1235
1236 async with AsyncSessionLocal() as session:
1237 payload = await _extract_proposal_symbol_data(session, repo_id, proposal_id)
1238 if payload is None:
1239 return MusehubToolResult(
1240 ok=False, error_code="proposal_not_found",
1241 error_message=f"Proposal '{proposal_id}' not found in repo '{repo_id}'.",
1242 hint="Call musehub_list_proposals() to see open proposals.",
1243 )
1244 _proposal, _commits, sym_added_names, sym_modified_names, sym_deleted_names, _bc = payload
1245 data: JSONObject = {
1246 "sym_added": len(sym_added_names),
1247 "sym_modified": len(sym_modified_names),
1248 "sym_deleted": len(sym_deleted_names),
1249 "sym_added_names": sym_added_names,
1250 "sym_modified_names": sym_modified_names,
1251 "sym_deleted_names": sym_deleted_names,
1252 }
1253 return MusehubToolResult(ok=True, data=data)
1254
1255
1256 async def execute_read_proposal_breakage(repo_id: str, proposal_id: str) -> MusehubToolResult:
1257 """Return the list of breaking changes for a merge proposal.
1258
1259 Args:
1260 repo_id: ID of the repository.
1261 proposal_id: ID of the merge proposal.
1262
1263 Returns:
1264 ``MusehubToolResult`` with breaking_changes list and count.
1265 An empty list means no breaking changes were detected.
1266 """
1267 if (err := _check_db_available()) is not None:
1268 return err
1269
1270 async with AsyncSessionLocal() as session:
1271 payload = await _extract_proposal_symbol_data(session, repo_id, proposal_id)
1272 if payload is None:
1273 return MusehubToolResult(
1274 ok=False, error_code="proposal_not_found",
1275 error_message=f"Proposal '{proposal_id}' not found in repo '{repo_id}'.",
1276 hint="Call musehub_list_proposals() to see open proposals.",
1277 )
1278 _proposal, _commits, _add, _mod, _del, breaking_changes = payload
1279 data: JSONObject = {
1280 "breaking_changes": breaking_changes[:50],
1281 "breakage_count": len(breaking_changes),
1282 }
1283 return MusehubToolResult(ok=True, data=data)
1284
1285
1286 async def execute_list_labels(repo_id: str) -> MusehubToolResult:
1287 """Return all labels defined in a MuseHub repository, ordered by name.
1288
1289 The list is publicly accessible for public repos. Use the ``label_id``
1290 values returned here with ``musehub_update_label`` and ``musehub_delete_label``.
1291
1292 Args:
1293 repo_id: ID of the repository.
1294
1295 Returns:
1296 ``MusehubToolResult`` with ``data.labels`` list.
1297 """
1298 if (err := _check_db_available()) is not None:
1299 return err
1300
1301 async with AsyncSessionLocal() as session:
1302 repo = await musehub_repository.get_repo(session, repo_id)
1303 if repo is None:
1304 return MusehubToolResult(
1305 ok=False,
1306 error_code="repo_not_found",
1307 error_message=f"Repository '{repo_id}' not found.",
1308 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
1309 )
1310
1311 from sqlalchemy import text
1312 result = await session.execute(
1313 text(
1314 "SELECT id AS label_id, repo_id, name, color, description "
1315 "FROM musehub_labels "
1316 "WHERE repo_id = :repo_id "
1317 "ORDER BY name ASC"
1318 ),
1319 {"repo_id": repo_id},
1320 )
1321 rows = result.mappings().all()
1322 labels: list[JSONObject] = [
1323 {
1324 "label_id": str(r["label_id"]),
1325 "repo_id": str(r["repo_id"]),
1326 "name": str(r["name"]),
1327 "color": str(r["color"]),
1328 "description": r["description"] or "",
1329 }
1330 for r in rows
1331 ]
1332 data: JSONObject = {
1333 "repo_id": repo_id,
1334 "total": len(labels),
1335 "labels": labels,
1336 }
1337 return MusehubToolResult(ok=True, data=data)
1338
1339
1340 async def execute_list_releases(repo_id: str) -> MusehubToolResult:
1341 """Return all releases for a MuseHub repository, ordered newest first.
1342
1343 Args:
1344 repo_id: ID of the repository.
1345
1346 Returns:
1347 ``MusehubToolResult`` with ``data.releases`` list.
1348 """
1349 if (err := _check_db_available()) is not None:
1350 return err
1351
1352 async with AsyncSessionLocal() as session:
1353 repo = await musehub_repository.get_repo(session, repo_id)
1354 if repo is None:
1355 return MusehubToolResult(
1356 ok=False,
1357 error_code="repo_not_found",
1358 error_message=f"Repository '{repo_id}' not found.",
1359 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
1360 )
1361 from musehub.services import musehub_releases
1362 release_result = await musehub_releases.list_releases(session, repo_id)
1363 data: JSONObject = {
1364 "repo_id": repo_id,
1365 "total": release_result.total,
1366 "releases": [
1367 {
1368 "release_id": r.release_id,
1369 "tag": r.tag,
1370 "title": r.title,
1371 "body": r.body,
1372 "is_prerelease": r.is_prerelease,
1373 "commit_id": r.commit_id,
1374 "author": r.author,
1375 "created_at": r.created_at.isoformat() if r.created_at else None,
1376 }
1377 for r in release_result.releases
1378 ],
1379 }
1380 return MusehubToolResult(ok=True, data=data)
1381
1382
1383 async def execute_get_repo(
1384 repo_id: str | None = None,
1385 owner: str | None = None,
1386 slug: str | None = None,
1387 actor: str = "",
1388 ) -> MusehubToolResult:
1389 """Return metadata for a single repository by repo_id or owner+slug.
1390
1391 Provide either ``repo_id`` or both ``owner`` and ``slug``. Public
1392 repos are readable by any caller. Private repos require ``actor`` to be the
1393 owner or an accepted collaborator — otherwise ``forbidden`` is returned.
1394
1395 The ``forbidden`` and ``repo_not_found`` error messages are intentionally
1396 identical to prevent leaking the existence of private repos to unauthorised
1397 callers.
1398
1399 Args:
1400 repo_id: ID of the repository. Takes precedence over owner/slug when
1401 both are provided.
1402 owner: Repository owner handle (used together with ``slug``).
1403 slug: URL-safe repository slug (used together with ``owner``).
1404 actor: Authenticated caller's MSign handle. Empty string = unauthenticated.
1405
1406 Returns:
1407 ``MusehubToolResult`` with ``data`` containing the repo metadata fields:
1408 ``repo_id``, ``name``, ``owner``, ``slug``, ``visibility``,
1409 ``description``, ``tags``, ``default_branch``, ``clone_url``,
1410 ``created_at``, ``updated_at``, ``pushed_at``.
1411 """
1412 if (err := _check_db_available()) is not None:
1413 return err
1414
1415 if not repo_id and not (owner and slug):
1416 return MusehubToolResult(
1417 ok=False,
1418 error_code="invalid_args",
1419 error_message="Provide repo_id or both owner and slug.",
1420 hint="Example: musehub_get_repo(owner='gabriel', slug='my-repo')",
1421 )
1422
1423 async with AsyncSessionLocal() as session:
1424 from sqlalchemy import select as _select
1425 from musehub.services import musehub_repository
1426 from musehub.db import musehub_collaborator_models as _collab_db
1427
1428 repo = None
1429 if repo_id:
1430 repo = await musehub_repository.get_repo(session, repo_id)
1431 if repo is None and owner and slug:
1432 repo = await musehub_repository.get_repo_by_owner_slug(session, owner, slug)
1433
1434 if repo is None:
1435 return MusehubToolResult(
1436 ok=False,
1437 error_code="repo_not_found",
1438 error_message="Repository not found or access denied.",
1439 hint="Use musehub_search_repos() to discover public repositories.",
1440 )
1441
1442 # Enforce visibility: private repos require actor to be owner or collaborator.
1443 if repo.visibility == "private" and actor != repo.owner:
1444 collab_result = await session.execute(
1445 _select(_collab_db.MusehubCollaborator).where(
1446 _collab_db.MusehubCollaborator.repo_id == repo.repo_id,
1447 _collab_db.MusehubCollaborator.identity_handle == actor,
1448 _collab_db.MusehubCollaborator.accepted_at.is_not(None),
1449 )
1450 )
1451 if collab_result.scalars().first() is None:
1452 return MusehubToolResult(
1453 ok=False,
1454 error_code="repo_not_found",
1455 error_message="Repository not found or access denied.",
1456 hint="Use musehub_search_repos() to discover public repositories.",
1457 )
1458
1459 data: JSONObject = {
1460 "repo_id": repo.repo_id,
1461 "name": repo.name,
1462 "owner": repo.owner,
1463 "slug": repo.slug,
1464 "visibility": repo.visibility,
1465 "description": repo.description or "",
1466 "tags": list(repo.tags) if repo.tags else [],
1467 "default_branch": repo.default_branch,
1468 "clone_url": repo.clone_url or "",
1469 "created_at": repo.created_at.isoformat() if repo.created_at else "",
1470 "updated_at": repo.updated_at.isoformat() if repo.updated_at else "",
1471 "pushed_at": repo.pushed_at.isoformat() if repo.pushed_at else "",
1472 }
1473 return MusehubToolResult(ok=True, data=data)
1474
1475
1476 async def execute_list_repos(
1477 actor: str,
1478 limit: int = 20,
1479 cursor: str | None = None,
1480 ) -> MusehubToolResult:
1481 """List repositories owned by or collaborated on by the authenticated user.
1482
1483 Results are ordered newest-first. Use ``cursor`` from a previous response
1484 to page through results.
1485
1486 Args:
1487 actor: Authenticated user handle (MSign handle). Required — agents
1488 can only list their own repos.
1489 limit: Maximum repos per page (default 20, max 100).
1490 cursor: Opaque pagination cursor from a previous ``next_cursor`` field.
1491
1492 Returns:
1493 ``MusehubToolResult`` with ``data.repos`` (list), ``data.total`` (int),
1494 and ``data.next_cursor`` (string or null).
1495 """
1496 if (err := _check_db_available()) is not None:
1497 return err
1498
1499 if not actor:
1500 return MusehubToolResult(
1501 ok=False,
1502 error_code="forbidden",
1503 error_message="Authentication required to list your repositories.",
1504 hint="Provide a valid MSign Authorization header.",
1505 )
1506
1507 capped = min(max(1, limit), 100)
1508
1509 async with AsyncSessionLocal() as session:
1510 from musehub.services import musehub_repository
1511
1512 result = await musehub_repository.list_repos_for_user(
1513 session,
1514 user_id=actor,
1515 limit=capped,
1516 cursor=cursor,
1517 )
1518
1519 data: JSONObject = {
1520 "total": result.total,
1521 "next_cursor": result.next_cursor,
1522 "repos": [
1523 {
1524 "repo_id": r.repo_id,
1525 "name": r.name,
1526 "owner": r.owner,
1527 "slug": r.slug,
1528 "visibility": r.visibility,
1529 "description": r.description or "",
1530 "tags": list(r.tags) if r.tags else [],
1531 "default_branch": r.default_branch or "main",
1532 "created_at": r.created_at.isoformat() if r.created_at else "",
1533 "pushed_at": r.pushed_at.isoformat() if r.pushed_at else "",
1534 }
1535 for r in result.repos
1536 ],
1537 }
1538 return MusehubToolResult(ok=True, data=data)
1539
1540
1541 async def execute_search_repos(
1542 query: str | None = None,
1543 domain: str | None = None,
1544 tags: list[str] | None = None,
1545 limit: int = 20,
1546 ) -> MusehubToolResult:
1547 """Discover public repositories by text query, domain, or tags.
1548
1549 All filters are optional and combined with AND logic.
1550
1551 Args:
1552 query: Free-text query matched against repo names and descriptions.
1553 domain: Filter by domain scoped ID (e.g. ``"@gabriel/midi"``).
1554 tags: Filter repos that have all of these tags.
1555 limit: Maximum results to return (default: 20, max: 100).
1556
1557 Returns:
1558 ``MusehubToolResult`` with ``data.repos`` list.
1559 """
1560 if (err := _check_db_available()) is not None:
1561 return err
1562
1563 capped_limit = min(max(1, limit), 100)
1564
1565 async with AsyncSessionLocal() as session:
1566 from musehub.services import musehub_discover
1567 explore = await musehub_discover.list_public_repos(
1568 session,
1569 page_size=min(capped_limit * 3, 100),
1570 )
1571 all_repos = explore.repos
1572
1573 filtered = []
1574 for r in all_repos:
1575 if query and query.lower() not in (r.name or "").lower() and \
1576 query.lower() not in (r.description or "").lower():
1577 continue
1578 if domain and domain.lower() not in (r.description or "").lower() \
1579 and not any(domain.lower() in t.lower() for t in (r.tags or [])):
1580 continue
1581 if tags:
1582 repo_tags: list[str] = list(r.tags) if r.tags else []
1583 if not all(t in repo_tags for t in tags):
1584 continue
1585 filtered.append(r)
1586 if len(filtered) >= capped_limit:
1587 break
1588
1589 data: JSONObject = {
1590 "total": len(filtered),
1591 "repos": [
1592 {
1593 "repo_id": r.repo_id,
1594 "owner": r.owner,
1595 "slug": r.slug,
1596 "name": r.name,
1597 "description": r.description,
1598 "tags": list(r.tags) if r.tags else [],
1599 }
1600 for r in filtered
1601 ],
1602 }
1603 return MusehubToolResult(ok=True, data=data)
1604
1605
1606 # ── Domain executor functions ─────────────────────────────────────────────────
1607
1608
1609 async def execute_list_domains(
1610 query: str | None = None,
1611 viewer_type: str | None = None,
1612 verified: bool | None = None,
1613 limit: int = 20,
1614 cursor: str | None = None,
1615 ) -> MusehubToolResult:
1616 """List registered Muse domain plugins."""
1617 if (err := _check_db_available()) is not None:
1618 return err
1619
1620 capped = min(max(1, limit), 100)
1621
1622 async with AsyncSessionLocal() as session:
1623 from musehub.services import musehub_domains as _domains_svc
1624 response = await _domains_svc.list_domains(
1625 session,
1626 query=query,
1627 verified_only=verified is True,
1628 cursor=cursor,
1629 limit=capped,
1630 )
1631
1632 domains_out: list[JSONValue] = []
1633 for d in response.domains:
1634 if viewer_type and d.viewer_type != viewer_type:
1635 continue
1636 domains_out.append({
1637 "domain_id": d.domain_id,
1638 "scoped_id": d.scoped_id,
1639 "display_name": d.display_name,
1640 "description": d.description,
1641 "version": d.version,
1642 "viewer_type": d.viewer_type,
1643 "install_count": d.install_count,
1644 "is_verified": d.is_verified,
1645 })
1646
1647 return MusehubToolResult(ok=True, data={"total": response.total, "domains": domains_out, "nextCursor": response.next_cursor})
1648
1649
1650 async def execute_read_domain(scoped_id: str) -> MusehubToolResult:
1651 """Fetch the full manifest for a Muse domain plugin by its scoped ID."""
1652 if (err := _check_db_available()) is not None:
1653 return err
1654
1655 parts = scoped_id.lstrip("@").split("/", 1)
1656 if len(parts) != 2:
1657 return MusehubToolResult(
1658 ok=False,
1659 error_code="invalid_args",
1660 error_message=f"Invalid scoped_id format: {scoped_id!r}. Expected '@author/slug'.",
1661 )
1662 author_slug, slug = parts
1663
1664 async with AsyncSessionLocal() as session:
1665 from musehub.services import musehub_domains as _domains_svc
1666 domain = await _domains_svc.get_domain_by_scoped_id(session, author_slug, slug)
1667 if domain is None:
1668 return MusehubToolResult(
1669 ok=False, error_code="domain_not_found",
1670 error_message=f"Domain {scoped_id!r} not found.",
1671 )
1672
1673 return MusehubToolResult(ok=True, data={
1674 "domain_id": domain.domain_id,
1675 "scoped_id": domain.scoped_id,
1676 "display_name": domain.display_name,
1677 "description": domain.description,
1678 "version": domain.version,
1679 "manifest_hash": domain.manifest_hash,
1680 "viewer_type": domain.viewer_type,
1681 "capabilities": domain.capabilities,
1682 "install_count": domain.install_count,
1683 "is_verified": domain.is_verified,
1684 "is_deprecated": domain.is_deprecated,
1685 })
1686
1687
1688 async def execute_read_domain_insights(
1689 repo_id: str,
1690 dimension: str = "overview",
1691 ref: str | None = None,
1692 ) -> MusehubToolResult:
1693 """Return domain insights for a repo — delegates to the analysis executor."""
1694 return await execute_get_analysis(repo_id, dimension=dimension)
1695
1696
1697 async def execute_read_view(
1698 repo_id: str,
1699 ref: str | None = None,
1700 dimension: str | None = None,
1701 ) -> MusehubToolResult:
1702 """Return the universal viewer payload for a repo at a given ref."""
1703 if (err := _check_db_available()) is not None:
1704 return err
1705
1706 async with AsyncSessionLocal() as session:
1707 from musehub.services import musehub_repository as _repo_svc
1708 from sqlalchemy import select
1709
1710 repo = await _repo_svc.get_repo(session, repo_id)
1711 if repo is None:
1712 return MusehubToolResult(
1713 ok=False, error_code="repo_not_found",
1714 error_message=f"Repo {repo_id!r} not found.",
1715 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
1716 )
1717
1718 default_branch = "main"
1719 resolved_ref = ref or str(default_branch)
1720
1721 branch_row = (await session.execute(
1722 select(MusehubBranch).where(
1723 MusehubBranch.repo_id == repo_id,
1724 MusehubBranch.name == resolved_ref,
1725 )
1726 )).scalar_one_or_none()
1727 head_commit_id = branch_row.head_commit_id if branch_row else None
1728
1729 domain_info: JSONObject = {}
1730 if repo.domain_id:
1731 from musehub.db.musehub_domain_models import MusehubDomain
1732 dom = await session.get(MusehubDomain, repo.domain_id)
1733 if dom:
1734 domain_info = {
1735 "scoped_id": f"@{dom.author_slug}/{dom.slug}",
1736 "display_name": dom.display_name,
1737 "viewer_type": dom.viewer_type,
1738 "capabilities": dom.capabilities,
1739 }
1740
1741 return MusehubToolResult(ok=True, data={
1742 "repo_id": repo_id,
1743 "owner": repo.owner,
1744 "slug": repo.slug,
1745 "ref": resolved_ref,
1746 "head_commit_id": head_commit_id,
1747 "domain": domain_info,
1748 "dimension": dimension,
1749 })
1750
1751
1752 # ── Muse CLI + auth executor functions ───────────────────────────────────────
1753
1754
1755 async def execute_whoami(user_id: str | None) -> MusehubToolResult:
1756 """Return identity information for the currently authenticated caller."""
1757 if user_id is None:
1758 return MusehubToolResult(ok=True, data={"authenticated": False, "user_id": None})
1759
1760 if (err := _check_db_available()) is not None:
1761 return MusehubToolResult(ok=True, data={"authenticated": True, "user_id": user_id})
1762
1763 async with AsyncSessionLocal() as session:
1764 from musehub.services import musehub_repository as _repo_svc
1765 repos_result = await _repo_svc.list_repos_for_user(session, user_id)
1766 repo_count = len(repos_result.repos) if repos_result else 0
1767 return MusehubToolResult(ok=True, data={
1768 "authenticated": True,
1769 "user_id": user_id,
1770 "repo_count": repo_count,
1771 "hub_url": "https://musehub.ai",
1772 })
1773
1774
1775 async def execute_muse_clone(
1776 owner: str,
1777 slug: str,
1778 ref: str | None = None,
1779 ) -> MusehubToolResult:
1780 """Return the clone URL and repo metadata for a MuseHub repository."""
1781 if (err := _check_db_available()) is not None:
1782 return err
1783
1784 async with AsyncSessionLocal() as session:
1785 from musehub.services import musehub_repository as _repo_svc
1786 repo = await _repo_svc.get_repo_by_owner_slug(session, owner, slug)
1787 if repo is None:
1788 return MusehubToolResult(
1789 ok=False, error_code="repo_not_found",
1790 error_message=f"Repo {owner}/{slug} not found.",
1791 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
1792 )
1793
1794 from musehub.config import settings
1795 clone_url = f"{settings.public_url.rstrip('/')}/{owner}/{slug}"
1796 ref_part = f" --branch {ref}" if ref else ""
1797
1798 return MusehubToolResult(ok=True, data={
1799 "repo_id": repo.repo_id,
1800 "owner": repo.owner,
1801 "slug": repo.slug,
1802 "name": repo.name,
1803 "clone_url": clone_url,
1804 "command": f"muse clone {clone_url}{ref_part}",
1805 "default_branch": str("main"),
1806 "visibility": repo.visibility,
1807 })
1808
1809
1810 async def execute_muse_push(
1811 repo_id: str,
1812 branch: str,
1813 head_commit_id: str,
1814 commits: list[JSONValue],
1815 snapshots: list[JSONValue] | None = None,
1816 blobs: list[JSONValue] | None = None,
1817 force: bool = False,
1818 user_id: str = "",
1819 ) -> MusehubToolResult:
1820 """Push commits, snapshots, and binary blobs to a MuseHub repository.
1821
1822 Enforces a per-user storage quota (``MCP_PUSH_PER_USER_QUOTA_BYTES``) to
1823 prevent runaway agent loops from filling the disk. The quota counts bytes
1824 already stored across all repos owned by ``user_id`` plus the incoming
1825 object payload of this push.
1826 """
1827 if not user_id:
1828 return MusehubToolResult(
1829 ok=False, error_code="unauthenticated",
1830 error_message="Authentication required to push. Run `muse auth keygen` then `muse auth register --agent`.",
1831 )
1832
1833 if (err := _check_db_available()) is not None:
1834 return err
1835
1836 async with AsyncSessionLocal() as session:
1837 from musehub.services import musehub_repository as _repo_svc, musehub_sync as _sync_svc
1838 from musehub.models.musehub import (
1839 CommitInput as _CommitInput,
1840 ObjectInput as _ObjectInput,
1841 SnapshotInput as _SnapshotInput,
1842 )
1843 from musehub.config import settings as _settings
1844 from sqlalchemy import select as _select, func as _func
1845
1846 repo = await _repo_svc.get_repo(session, repo_id)
1847 if repo is None:
1848 return MusehubToolResult(
1849 ok=False, error_code="repo_not_found",
1850 error_message=f"Repo {repo_id!r} not found.",
1851 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
1852 )
1853
1854 commit_inputs = [_CommitInput.model_validate(c) for c in commits]
1855 snapshot_inputs = [_SnapshotInput.model_validate(s) for s in (snapshots or [])]
1856 blob_inputs = [_ObjectInput.model_validate(o) for o in (blobs or [])]
1857
1858 # ── Per-user storage quota ────────────────────────────────────────────
1859 quota_bytes = _settings.mcp_push_per_user_quota_bytes
1860 if quota_bytes > 0:
1861 # Sum size_bytes for all objects referenced by any repo owned by this
1862 # user. JOIN through musehub_object_refs (not musehub_objects.repo_id)
1863 # so dedup is correct and the query survives the phase-5 column drop.
1864 used_q = await session.execute(
1865 _select(_func.coalesce(_func.sum(MusehubObject.size_bytes), 0))
1866 .join(
1867 MusehubObjectRef,
1868 MusehubObject.object_id == MusehubObjectRef.object_id,
1869 )
1870 .join(
1871 MusehubRepo,
1872 MusehubObjectRef.repo_id == MusehubRepo.repo_id,
1873 )
1874 .where(
1875 MusehubRepo.owner_user_id == user_id,
1876 )
1877 )
1878 used_bytes: int = int(used_q.scalar() or 0)
1879
1880 # Estimate incoming object size from base64 payload (pre-decode).
1881 # base64 overhead is 4/3, so raw bytes ≈ len(b64) * 3/4.
1882 incoming_bytes = sum(
1883 int(len(o.content_b64) * 0.75)
1884 for o in blob_inputs
1885 if hasattr(o, "content_b64") and o.content_b64
1886 )
1887
1888 if used_bytes + incoming_bytes > quota_bytes:
1889 quota_gb = quota_bytes / (1024 ** 3)
1890 return MusehubToolResult(
1891 ok=False,
1892 error_code="quota_exceeded",
1893 error_message=(
1894 f"Storage quota exceeded: your account has used "
1895 f"{used_bytes / (1024**3):.2f} GB of the {quota_gb:.1f} GB limit. "
1896 "Delete unused objects or contact support to increase your quota."
1897 ),
1898 )
1899
1900 try:
1901 push_result = await _sync_svc.ingest_push(
1902 session,
1903 repo_id=repo_id,
1904 branch=branch,
1905 head_commit_id=head_commit_id,
1906 commits=commit_inputs,
1907 snapshots=snapshot_inputs,
1908 objects=blob_inputs,
1909 force=force,
1910 author=user_id,
1911 )
1912 await session.commit()
1913
1914 return MusehubToolResult(ok=True, data={
1915 "repo_id": repo_id,
1916 "branch": branch,
1917 "remote_head": push_result.remote_head,
1918 "commits_pushed": len(commit_inputs),
1919 "snapshots_pushed": len(snapshot_inputs),
1920 "blobs_pushed": len(blob_inputs),
1921 })
1922 except ValueError as exc:
1923 code: MusehubErrorCode = "non_fast_forward" if "non_fast_forward" in str(exc) else "push_failed"
1924 return MusehubToolResult(ok=False, error_code=code, error_message=str(exc))
1925
1926
1927 async def execute_muse_pull(
1928 repo_id: str,
1929 branch: str | None = None,
1930 since_commit_id: str | None = None,
1931 blob_ids: list[str] | None = None,
1932 ) -> MusehubToolResult:
1933 """Fetch missing commits and blobs from a MuseHub repository."""
1934 if (err := _check_db_available()) is not None:
1935 return err
1936
1937 async with AsyncSessionLocal() as session:
1938 from musehub.services import musehub_repository as _repo_svc, musehub_sync as _sync_svc
1939
1940 repo = await _repo_svc.get_repo(session, repo_id)
1941 if repo is None:
1942 return MusehubToolResult(
1943 ok=False, error_code="repo_not_found",
1944 error_message=f"Repo {repo_id!r} not found.",
1945 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
1946 )
1947
1948 resolved_branch = branch or "main"
1949 # compute_pull_delta uses have_commits/have_objects as exclusion lists.
1950 # since_commit_id and blob_ids are treated as "have" hints.
1951 have_commits: list[str] = [since_commit_id] if since_commit_id else []
1952 have_objects: list[str] = list(blob_ids) if blob_ids else []
1953
1954 try:
1955 pull_result = await _sync_svc.compute_pull_delta(
1956 session,
1957 repo_id=repo_id,
1958 branch=resolved_branch,
1959 have_commits=have_commits,
1960 have_objects=have_objects,
1961 )
1962 commits_out: list[JSONValue] = [
1963 c.model_dump() for c in pull_result.commits
1964 ]
1965 blobs_out: list[JSONValue] = [
1966 {"object_id": o.object_id, "path": o.path}
1967 for o in pull_result.objects
1968 ]
1969 return MusehubToolResult(ok=True, data={
1970 "repo_id": repo_id,
1971 "branch": resolved_branch,
1972 "remote_head": pull_result.remote_head,
1973 "commits": commits_out,
1974 "blobs": blobs_out,
1975 })
1976 except Exception as exc:
1977 return MusehubToolResult(ok=False, error_code="pull_failed", error_message=str(exc))
1978
1979
1980 async def execute_muse_remote(
1981 owner: str,
1982 slug: str,
1983 ref: str | None = None,
1984 ) -> MusehubToolResult:
1985 """Return the remote URL, push/pull endpoints, and clone command for a MuseHub repo.
1986
1987 Covers both 'muse remote -v' and 'muse clone' use cases. The optional ``ref``
1988 parameter is appended to the clone command (``--branch <ref>``) when provided.
1989 """
1990 if (err := _check_db_available()) is not None:
1991 return err
1992
1993 async with AsyncSessionLocal() as session:
1994 from musehub.services import musehub_repository as _repo_svc
1995 repo = await _repo_svc.get_repo_by_owner_slug(session, owner, slug)
1996 if repo is None:
1997 return MusehubToolResult(
1998 ok=False, error_code="repo_not_found",
1999 error_message=f"Repo {owner}/{slug} not found.",
2000 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
2001 )
2002
2003 hub_url = "https://musehub.ai"
2004 remote_url = f"{hub_url}/{owner}/{slug}"
2005 api_base = f"{hub_url}/api/repos/{repo.repo_id}"
2006 ref_part = f" --branch {ref}" if ref else ""
2007
2008 return MusehubToolResult(ok=True, data={
2009 "repo_id": repo.repo_id,
2010 "owner": repo.owner,
2011 "slug": repo.slug,
2012 "name": repo.name,
2013 "remote_url": remote_url,
2014 "push_url": f"{api_base}/push",
2015 "pull_url": f"{api_base}/pull",
2016 "clone_url": remote_url,
2017 "clone_command": f"muse clone {remote_url}{ref_part}",
2018 "add_remote_command": f"muse remote add origin {remote_url}",
2019 "visibility": repo.visibility,
2020 })
2021
2022
2023 async def execute_muse_config(
2024 key: str | None = None,
2025 value: str | None = None,
2026 ) -> MusehubToolResult:
2027 """Read or describe Muse configuration keys relevant to MuseHub."""
2028 hub_config_keys: StrDict = {
2029 "musehub.token": "Ed25519 private key path for MSign authentication with MuseHub.",
2030 "musehub.url": "Base URL for the MuseHub instance (default: https://musehub.ai).",
2031 "musehub.username": "Your MuseHub username — default owner for new repos.",
2032 "core.editor": "Text editor for commit messages (e.g. 'vim', 'nano', 'code --wait').",
2033 "user.name": "Your display name used in commit author fields.",
2034 "user.email": "Your email address used in commit author fields.",
2035 }
2036
2037 if key is None:
2038 return MusehubToolResult(ok=True, data={
2039 "config_keys": hub_config_keys,
2040 "hint": (
2041 "Use 'muse config set <key> <value>' to configure Muse. "
2042 "Run 'muse config list' to see all current values."
2043 ),
2044 })
2045
2046 description = hub_config_keys.get(key, f"Unknown key: {key!r}")
2047 result: JSONObject = {"key": key, "description": description}
2048 if value is not None:
2049 cli_cmd = f"muse config set {key} {value}"
2050 result["command"] = cli_cmd
2051 result["hint"] = f"Run this command in your terminal: {cli_cmd}"
2052
2053 return MusehubToolResult(ok=True, data=result)
2054
2055
2056 async def execute_musehub_publish_domain(
2057 author_slug: str,
2058 slug: str,
2059 display_name: str,
2060 description: str,
2061 capabilities: JSONObject,
2062 viewer_type: str,
2063 version: str = "0.1.0",
2064 user_id: str = "",
2065 ) -> MusehubToolResult:
2066 """Register a new Muse domain plugin in the MuseHub marketplace.
2067
2068 Validates the capabilities manifest, creates the domain record, and returns
2069 the scoped identifier and content-addressed manifest hash. The domain is
2070 immediately discoverable via musehub_list_domains.
2071
2072 Authentication is required — the caller must provide their user_id (resolved
2073 from the MSign context by the dispatcher). The ``author_slug`` must match
2074 the handle registered for the authenticated ``user_id`` to prevent namespace
2075 squatting (publishing under another user's @handle).
2076 """
2077 if not user_id:
2078 return MusehubToolResult(
2079 ok=False,
2080 error_code="unauthenticated",
2081 error_message=(
2082 "Authentication required to publish a domain. "
2083 "Run `muse auth keygen` then `muse auth register --agent` to register an Ed25519 identity."
2084 ),
2085 )
2086
2087 if (err := _check_db_available()) is not None:
2088 return err
2089
2090 async with AsyncSessionLocal() as session:
2091 from musehub.services import musehub_domains as _domain_svc
2092 from sqlalchemy import select as _select
2093
2094 # ── Namespace squatting guard ─────────────────────────────────────────
2095 # Verify that the authenticated user actually owns the author_slug they
2096 # claim. Look up the identity whose handle == author_slug and whose
2097 # id matches the MSign handle. If no match, reject — an agent cannot
2098 # publish under someone else's @handle.
2099 identity_stmt = _select(MusehubIdentity).where(
2100 MusehubIdentity.handle == author_slug,
2101 )
2102 identity_row = (await session.execute(identity_stmt)).scalar_one_or_none()
2103 # Accept the publish if:
2104 # (a) an identity with this handle exists and its id == user_id, OR
2105 # (b) no identity row exists yet — the user is publishing their first
2106 # domain before creating a profile (author_user_id will own it).
2107 if identity_row is not None and identity_row.identity_id != user_id:
2108 return MusehubToolResult(
2109 ok=False,
2110 error_code="forbidden",
2111 error_message=(
2112 f"Cannot publish under '@{author_slug}': that handle belongs to "
2113 "a different account. Use your own handle as author_slug."
2114 ),
2115 )
2116
2117 try:
2118 domain = await _domain_svc.create_domain(
2119 session,
2120 author_user_id=user_id,
2121 author_slug=author_slug,
2122 slug=slug,
2123 display_name=display_name,
2124 description=description,
2125 capabilities=capabilities,
2126 viewer_type=viewer_type,
2127 version=version,
2128 )
2129 await session.commit()
2130 except Exception as exc:
2131 exc_str = str(exc)
2132 if "unique" in exc_str.lower() or "duplicate" in exc_str.lower() or "integrity" in exc_str.lower():
2133 return MusehubToolResult(
2134 ok=False,
2135 error_code="domain_conflict",
2136 error_message=(
2137 f"Domain '@{author_slug}/{slug}' is already registered. "
2138 "Choose a different slug or use a versioned update."
2139 ),
2140 )
2141 logger.exception("Failed to publish domain @%s/%s", author_slug, slug)
2142 return MusehubToolResult(
2143 ok=False,
2144 error_code="publish_failed",
2145 error_message=f"Failed to publish domain: {exc_str}",
2146 )
2147
2148 return MusehubToolResult(ok=True, data={
2149 "domain_id": domain.domain_id,
2150 "scoped_id": domain.scoped_id,
2151 "manifest_hash": domain.manifest_hash,
2152 "display_name": domain.display_name,
2153 "version": domain.version,
2154 "hint": (
2155 f"Domain published! Use scoped_id='{domain.scoped_id}' when creating "
2156 "repos with musehub_create_repo. Other agents can discover it via "
2157 "musehub_list_domains or musehub_read_domain."
2158 ),
2159 })
2160
2161
2162 # ── Intelligence Hub read tools ───────────────────────────────────────────────
2163
2164
2165 async def execute_read_intel_health_score(repo_id: str) -> MusehubToolResult:
2166 """Return the repository health score (0–100) and penalty breakdown."""
2167 if (err := _check_db_available()) is not None:
2168 return err
2169
2170 async with AsyncSessionLocal() as session:
2171 from musehub.services.musehub_symbol_indexer import load_symbol_history as _lsh, get_index_meta as _gim
2172 from musehub.services.musehub_intel import compute_intel as _ci
2173
2174 index_meta = await _gim(session, repo_id)
2175 if index_meta is None:
2176 return MusehubToolResult(
2177 ok=False, error_code="not_ready",
2178 error_message="Symbol index has not been built for this repository. Push a commit first.",
2179 hint="The symbol index is not yet built. Push commits first, then the index will be built automatically.",
2180 )
2181 symbol_history = await _lsh(session, repo_id)
2182 snap = _ci(symbol_history=symbol_history, recent_breaking_changes=[])
2183 return MusehubToolResult(ok=True, data={
2184 "health_score": snap.health_score,
2185 "health_label": snap.health_label,
2186 "total_symbols": snap.total_symbols,
2187 "total_commits_indexed": snap.total_commits_indexed,
2188 "alerts": {
2189 "hotspot_count": snap.alert_hotspot_count,
2190 "dead_count": snap.alert_dead_count,
2191 "blast_risk_count": snap.alert_blast_risk_count,
2192 "breaking_count": snap.alert_breaking_count,
2193 },
2194 })
2195
2196
2197 async def execute_read_intel_hotspots(repo_id: str) -> MusehubToolResult:
2198 """Return symbols ranked by change frequency."""
2199 if (err := _check_db_available()) is not None:
2200 return err
2201
2202 async with AsyncSessionLocal() as session:
2203 from musehub.services.musehub_symbol_indexer import load_symbol_history as _lsh
2204 from musehub.services.musehub_intel import compute_intel as _ci
2205
2206 symbol_history = await _lsh(session, repo_id)
2207 snap = _ci(symbol_history=symbol_history, recent_breaking_changes=[])
2208 return MusehubToolResult(ok=True, data={
2209 "count": len(snap.hotspots),
2210 "hotspots": [
2211 {
2212 "address": h.address,
2213 "change_count": h.change_count,
2214 "last_changed": h.last_changed,
2215 }
2216 for h in snap.hotspots
2217 ],
2218 })
2219
2220
2221 async def execute_read_intel_dead(repo_id: str) -> MusehubToolResult:
2222 """Return dead code candidates sorted by staleness."""
2223 if (err := _check_db_available()) is not None:
2224 return err
2225
2226 async with AsyncSessionLocal() as session:
2227 from musehub.services.musehub_symbol_indexer import load_symbol_history as _lsh
2228 from musehub.services.musehub_intel import compute_intel as _ci
2229
2230 symbol_history = await _lsh(session, repo_id)
2231 snap = _ci(symbol_history=symbol_history, recent_breaking_changes=[])
2232 return MusehubToolResult(ok=True, data={
2233 "count": len(snap.dead_candidates),
2234 "dead_candidates": [
2235 {
2236 "address": d.address,
2237 "days_cold": d.days_cold,
2238 "blast_radius": d.blast_radius,
2239 "added_at": d.added_at,
2240 }
2241 for d in snap.dead_candidates
2242 ],
2243 })
2244
2245
2246 async def execute_read_intel_blast_risk(repo_id: str) -> MusehubToolResult:
2247 """Return symbols with the highest historical co-change blast radius."""
2248 if (err := _check_db_available()) is not None:
2249 return err
2250
2251 async with AsyncSessionLocal() as session:
2252 from musehub.services.musehub_symbol_indexer import load_symbol_history as _lsh
2253 from musehub.services.musehub_intel import compute_intel as _ci
2254
2255 symbol_history = await _lsh(session, repo_id)
2256 snap = _ci(symbol_history=symbol_history, recent_breaking_changes=[])
2257 return MusehubToolResult(ok=True, data={
2258 "count": len(snap.blast_risk),
2259 "blast_risk": [
2260 {
2261 "address": b.address,
2262 "co_change_count": b.co_change_count,
2263 "top_co_symbols": b.top_co_symbols,
2264 }
2265 for b in snap.blast_risk
2266 ],
2267 })
2268
2269
2270 # ── Coordination tools ────────────────────────────────────────────────────────
2271
2272 async def execute_read_coord_swarm(repo_id: str) -> MusehubToolResult:
2273 """Return a high-level swarm snapshot: active agents, reservation counts, task queue."""
2274 if (err := _check_db_available()) is not None:
2275 return err
2276
2277 async with AsyncSessionLocal() as session:
2278 from musehub.services.musehub_coord_server import (
2279 list_reservations,
2280 list_tasks,
2281 _reservation_to_dict,
2282 )
2283
2284 active_reservations = await list_reservations(session, repo_id, include_expired=False, include_released=False)
2285 pending_tasks = await list_tasks(session, repo_id, status="pending", limit=500)
2286 claimed_tasks = await list_tasks(session, repo_id, status="claimed", limit=500)
2287 done_tasks = await list_tasks(session, repo_id, status="completed", limit=200)
2288 failed_tasks = await list_tasks(session, repo_id, status="failed", limit=200)
2289
2290 active_agent_ids: set[str] = set()
2291 for r in active_reservations:
2292 active_agent_ids.add(r.agent_id)
2293 for t in claimed_tasks:
2294 if t.claimed_by:
2295 active_agent_ids.add(t.claimed_by)
2296
2297 agent_reservation_counts: IntDict = {}
2298 for r in active_reservations:
2299 agent_reservation_counts[r.agent_id] = agent_reservation_counts.get(r.agent_id, 0) + 1
2300
2301 queue_summary = {}
2302 for t in pending_tasks + claimed_tasks:
2303 q = t.queue or "default"
2304 if q not in queue_summary:
2305 queue_summary[q] = {"pending": 0, "claimed": 0}
2306 queue_summary[q][t.status] = queue_summary[q].get(t.status, 0) + 1
2307
2308 return MusehubToolResult(ok=True, data={
2309 "repo_id": repo_id,
2310 "active_agent_count": len(active_agent_ids),
2311 "active_agents": [
2312 {"agent_id": aid, "reservation_count": agent_reservation_counts.get(aid, 0)}
2313 for aid in sorted(active_agent_ids)
2314 ],
2315 "reservations": {
2316 "active": len(active_reservations),
2317 "symbols_reserved": [r.symbol_address for r in active_reservations],
2318 },
2319 "tasks": {
2320 "pending": len(pending_tasks),
2321 "claimed": len(claimed_tasks),
2322 "completed": len(done_tasks),
2323 "failed": len(failed_tasks),
2324 "queues": queue_summary,
2325 },
2326 })
2327
2328
2329 async def execute_list_coord_reservations(
2330 repo_id: str,
2331 *,
2332 agent_id: str | None = None,
2333 include_expired: bool = False,
2334 limit: int = 200,
2335 ) -> MusehubToolResult:
2336 """List active symbol reservations for a repo."""
2337 if (err := _check_db_available()) is not None:
2338 return err
2339
2340 async with AsyncSessionLocal() as session:
2341 from musehub.services.musehub_coord_server import list_reservations, _reservation_to_dict
2342
2343 rows = await list_reservations(
2344 session, repo_id,
2345 include_expired=include_expired,
2346 include_released=False,
2347 agent_id=agent_id,
2348 limit=limit,
2349 )
2350 return MusehubToolResult(ok=True, data={
2351 "repo_id": repo_id,
2352 "count": len(rows),
2353 "reservations": [_reservation_to_dict(r) for r in rows],
2354 })
2355
2356
2357 async def execute_read_coord_conflicts(
2358 repo_id: str,
2359 addresses: list[str],
2360 ) -> MusehubToolResult:
2361 """Check if the given symbol addresses are currently reserved by any agent."""
2362 if (err := _check_db_available()) is not None:
2363 return err
2364
2365 async with AsyncSessionLocal() as session:
2366 from musehub.services.musehub_coord_server import conflict_check
2367
2368 conflicts = await conflict_check(session, repo_id, addresses)
2369 return MusehubToolResult(ok=True, data={
2370 "repo_id": repo_id,
2371 "addresses_checked": addresses,
2372 "conflict_count": len(conflicts),
2373 "has_conflicts": len(conflicts) > 0,
2374 "conflicts": conflicts,
2375 })
2376
2377
2378 async def execute_list_coord_tasks(
2379 repo_id: str,
2380 *,
2381 queue: str | None = None,
2382 status: str | None = None,
2383 limit: int = 100,
2384 ) -> MusehubToolResult:
2385 """List coordination tasks for a repo, sorted by priority then created_at."""
2386 if (err := _check_db_available()) is not None:
2387 return err
2388
2389 async with AsyncSessionLocal() as session:
2390 from musehub.services.musehub_coord_server import list_tasks, _task_to_dict
2391
2392 rows = await list_tasks(session, repo_id, queue=queue, status=status, limit=limit)
2393 return MusehubToolResult(ok=True, data={
2394 "repo_id": repo_id,
2395 "count": len(rows),
2396 "tasks": [_task_to_dict(t) for t in rows],
2397 })
2398
2399
2400 async def execute_claim_coord_task(
2401 repo_id: str,
2402 task_id: str,
2403 agent_id: str,
2404 user_id: str,
2405 ) -> MusehubToolResult:
2406 """Atomically claim a pending task for an agent.
2407
2408 Returns an error if the task is already claimed, completed, or not found.
2409 """
2410 if (err := _check_db_available()) is not None:
2411 return err
2412
2413 async with AsyncSessionLocal() as session:
2414 from musehub.services.musehub_coord_server import claim_task, _task_to_dict
2415
2416 row = await claim_task(session, repo_id, task_id, agent_id)
2417 if row is None:
2418 return MusehubToolResult(
2419 ok=False,
2420 error_code="task_not_found",
2421 error_message="Task not found, already claimed, or completed.",
2422 hint="Call musehub_list_coord_tasks(status='pending') to find claimable tasks.",
2423 )
2424 return MusehubToolResult(ok=True, data=_task_to_dict(row))
2425
2426
2427 async def execute_complete_coord_task(
2428 repo_id: str,
2429 task_id: str,
2430 agent_id: str,
2431 result: JSONObject | None = None,
2432 ) -> MusehubToolResult:
2433 """Mark a claimed task as completed, optionally attaching a result payload."""
2434 if (err := _check_db_available()) is not None:
2435 return err
2436
2437 async with AsyncSessionLocal() as session:
2438 from musehub.services.musehub_coord_server import complete_task, _task_to_dict
2439
2440 row = await complete_task(session, repo_id, task_id, agent_id, result)
2441 if row is None:
2442 return MusehubToolResult(
2443 ok=False,
2444 error_code="task_not_found",
2445 error_message="Task not found or not claimed by this agent.",
2446 hint="Call musehub_list_coord_tasks(status='claimed') to see tasks claimed by your agent.",
2447 )
2448 return MusehubToolResult(ok=True, data=_task_to_dict(row))
2449
2450
2451 async def execute_fail_coord_task(
2452 repo_id: str,
2453 task_id: str,
2454 agent_id: str,
2455 reason: str = "",
2456 ) -> MusehubToolResult:
2457 """Mark a claimed task as failed with an optional failure reason."""
2458 if (err := _check_db_available()) is not None:
2459 return err
2460
2461 async with AsyncSessionLocal() as session:
2462 from musehub.services.musehub_coord_server import fail_task, _task_to_dict
2463
2464 row = await fail_task(session, repo_id, task_id, agent_id, reason)
2465 if row is None:
2466 return MusehubToolResult(
2467 ok=False,
2468 error_code="task_not_found",
2469 error_message="Task not found or not claimed by this agent.",
2470 hint="Call musehub_list_coord_tasks(status='claimed') to see tasks claimed by your agent.",
2471 )
2472 return MusehubToolResult(ok=True, data=_task_to_dict(row))
2473
2474
2475 async def execute_extend_coord_reservation(
2476 repo_id: str,
2477 reservation_id: str,
2478 extend_by_s: int = 300,
2479 ) -> MusehubToolResult:
2480 """Extend a reservation's expiry by the given number of seconds (30–3600)."""
2481 if (err := _check_db_available()) is not None:
2482 return err
2483
2484 extend_by_s = max(30, min(3600, extend_by_s))
2485
2486 async with AsyncSessionLocal() as session:
2487 from musehub.services.musehub_coord_server import extend_reservation, _reservation_to_dict
2488
2489 row = await extend_reservation(session, repo_id, reservation_id, extend_by_s)
2490 if row is None:
2491 return MusehubToolResult(
2492 ok=False,
2493 error_code="symbol_not_found",
2494 error_message="Reservation not found or already released.",
2495 )
2496 return MusehubToolResult(ok=True, data=_reservation_to_dict(row))
2497
2498
2499 async def execute_create_coord_reservation(
2500 repo_id: str,
2501 addresses: list[str],
2502 agent_id: str,
2503 ttl_s: int = 300,
2504 ) -> MusehubToolResult:
2505 """Create symbol reservations for an agent. One reservation_id covers all addresses."""
2506 if (err := _check_db_available()) is not None:
2507 return err
2508
2509 if not addresses:
2510 return MusehubToolResult(
2511 ok=False,
2512 error_code="missing_args",
2513 error_message="addresses must be a non-empty list of symbol addresses.",
2514 hint="Example: addresses=['src/engine.py::AudioEngine', 'src/mixer.py::Mixer']",
2515 )
2516
2517 from datetime import timedelta
2518 from musehub.core.genesis import compute_reservation_id
2519 from musehub.services.musehub_coord_server import _materialize_reservation, _utc_now
2520
2521 now = _utc_now()
2522 reservation_id = compute_reservation_id(
2523 repo_id, agent_id, ",".join(sorted(addresses)), now.isoformat()
2524 )
2525 expires_at = now + timedelta(seconds=max(30, min(3600, ttl_s)))
2526 payload = {
2527 "reservation_id": reservation_id,
2528 "run_id": agent_id,
2529 "addresses": addresses,
2530 "ttl_s": ttl_s,
2531 "created_at": now.isoformat(),
2532 "expires_at": expires_at.isoformat(),
2533 }
2534
2535 async with AsyncSessionLocal() as session:
2536 await _materialize_reservation(session, repo_id, reservation_id, payload, expires_at)
2537 await session.commit()
2538
2539 return MusehubToolResult(ok=True, data={
2540 "reservation_id": reservation_id,
2541 "repo_id": repo_id,
2542 "agent_id": agent_id,
2543 "addresses": addresses,
2544 "ttl_s": ttl_s,
2545 "expires_at": expires_at.isoformat(),
2546 "message": (
2547 f"Reserved {len(addresses)} symbol(s) for agent '{agent_id}'. "
2548 f"Expires in {ttl_s}s. Call musehub_extend_coord_reservation to renew, "
2549 f"musehub_delete_coord_reservation to free early."
2550 ),
2551 })
2552
2553
2554 async def execute_delete_coord_reservation(
2555 repo_id: str,
2556 reservation_id: str,
2557 agent_id: str,
2558 ) -> MusehubToolResult:
2559 """Release a symbol reservation, freeing the locked symbols for other agents."""
2560 if (err := _check_db_available()) is not None:
2561 return err
2562
2563 from musehub.services.musehub_coord_server import _materialize_release
2564
2565 payload = {"reservation_id": reservation_id, "agent_id": agent_id}
2566
2567 async with AsyncSessionLocal() as session:
2568 await _materialize_release(session, repo_id, payload)
2569 await session.commit()
2570
2571 return MusehubToolResult(ok=True, data={
2572 "reservation_id": reservation_id,
2573 "released": True,
2574 "message": f"Reservation {reservation_id[:8]}... released. Symbols are now free for other agents.",
2575 })
2576
2577
2578 async def execute_enqueue_coord_task(
2579 repo_id: str,
2580 queue: str,
2581 payload: JSONObject,
2582 agent_id: str,
2583 priority: int = 50,
2584 depends_on: list[str] | None = None,
2585 ) -> MusehubToolResult:
2586 """Enqueue a task for other agents (or yourself) to claim and execute."""
2587 if (err := _check_db_available()) is not None:
2588 return err
2589
2590 from musehub.core.genesis import compute_task_id
2591 from musehub.services.musehub_coord_server import _materialize_task, _utc_now
2592
2593 now = _utc_now()
2594 task_id = compute_task_id(repo_id, queue, agent_id, now.isoformat())
2595 task_payload = {
2596 "task_id": task_id,
2597 "queue": queue,
2598 "priority": max(0, min(100, priority)),
2599 "payload": payload,
2600 "created_by": agent_id,
2601 "created_at": now.isoformat(),
2602 "depends_on": depends_on or [],
2603 **payload,
2604 }
2605
2606 async with AsyncSessionLocal() as session:
2607 await _materialize_task(session, repo_id, task_id, task_payload, None)
2608 await session.commit()
2609
2610 return MusehubToolResult(ok=True, data={
2611 "task_id": task_id,
2612 "repo_id": repo_id,
2613 "queue": queue,
2614 "priority": priority,
2615 "status": "pending",
2616 "created_by": agent_id,
2617 "message": (
2618 f"Task {task_id[:8]}... enqueued in '{queue}' with priority {priority}. "
2619 f"Agents can claim it via musehub_claim_coord_task(task_id='{task_id}', agent_id=...)."
2620 ),
2621 })
2622
2623
2624 async def execute_read_cross_repo_impact(
2625 repo_id: str,
2626 address: str,
2627 ) -> MusehubToolResult:
2628 """Compute cross-repo blast radius for a symbol."""
2629 if (err := _check_db_available()) is not None:
2630 return err
2631
2632 async with AsyncSessionLocal() as session:
2633 from musehub.services.musehub_cross_repo import cross_repo_impact
2634
2635 repo = await session.get(MusehubRepo, repo_id)
2636 if repo is None:
2637 return MusehubToolResult(ok=False, error_code="repo_not_found", error_message="Repository not found.",
2638 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.")
2639
2640 impact = await cross_repo_impact(
2641 session,
2642 owner=repo.owner,
2643 source_repo_id=repo_id,
2644 address=address,
2645 )
2646 if impact is None:
2647 return MusehubToolResult(
2648 ok=False,
2649 error_code="symbol_not_found",
2650 error_message=f"Symbol '{address}' not found in repo.",
2651 hint="Call musehub_list_symbols() to browse the symbol index, or musehub_read_intel_index_status() to check if the index is built.",
2652 )
2653 return MusehubToolResult(
2654 ok=True,
2655 data={
2656 "address": impact.address,
2657 "source_repo_id": impact.source_repo_id,
2658 "source_repo_slug": impact.source_repo_slug,
2659 "local_commit_count": impact.local_commit_count,
2660 "local_co_changed": impact.local_co_changed,
2661 "external": [
2662 {"repo_id": e.repo_id, "repo_slug": e.repo_slug, "matches": e.matches}
2663 for e in impact.external
2664 ],
2665 },
2666 )
2667
2668
2669 async def execute_read_workspace_intel(
2670 owner: str,
2671 ) -> MusehubToolResult:
2672 """Return workspace health overview for an owner."""
2673 if (err := _check_db_available()) is not None:
2674 return err
2675
2676 async with AsyncSessionLocal() as session:
2677 from sqlalchemy import select
2678 from musehub.services.musehub_cross_repo import workspace_blast_risk_top_n
2679 from musehub.services.musehub_symbol_indexer import get_index_meta, load_symbol_history
2680 from musehub.services.musehub_intel import compute_intel
2681
2682 stmt = select(MusehubRepo).where(
2683 MusehubRepo.owner == owner,
2684 )
2685 result = await session.execute(stmt)
2686 repos = list(result.scalars().all())
2687
2688 if not repos:
2689 return MusehubToolResult(
2690 ok=False,
2691 error_code="repo_not_found",
2692 error_message=f"No repos found for owner '{owner}'.",
2693 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
2694 )
2695
2696 repo_summaries = []
2697 for repo in repos:
2698 meta = await get_index_meta(session, repo.repo_id)
2699 health_score: int | None = None
2700 try:
2701 sh = await load_symbol_history(session, repo.repo_id)
2702 if sh:
2703 snap = compute_intel(symbol_history=sh, recent_breaking_changes=[])
2704 health_score = snap.health_score
2705 except Exception:
2706 pass
2707 repo_summaries.append({
2708 "repo_id": repo.repo_id,
2709 "repo_slug": repo.name,
2710 "symbol_count": meta["symbol_count"] if meta else 0,
2711 "health_score": health_score,
2712 })
2713
2714 top_risk = await workspace_blast_risk_top_n(session, owner, top_n=10)
2715
2716 return MusehubToolResult(
2717 ok=True,
2718 data={
2719 "owner": owner,
2720 "repo_count": len(repos),
2721 "repos": repo_summaries,
2722 "top_risk_symbols": [
2723 {
2724 "address": e.address,
2725 "repo_slug": e.repo_slug,
2726 "co_change_count": e.co_change_count,
2727 }
2728 for e in top_risk
2729 ],
2730 },
2731 )
2732
2733
2734 def execute_read_prompt(
2735 name: str,
2736 arguments: StrDict | None = None,
2737 ) -> MusehubToolResult:
2738 """Return the fully assembled messages for a named MuseHub MCP prompt.
2739
2740 This is a tool-layer shim over ``prompts/get`` so that agents operating
2741 through clients that only support the ``tools/call`` MCP primitive (e.g.
2742 Cursor's agent API) can still access prompt content programmatically.
2743 Clients that natively support ``prompts/get`` can call that method directly;
2744 both paths produce identical output from the same underlying assembler.
2745
2746 Args:
2747 name: Prompt name — one of the ten ``musehub/*`` prompts, e.g.
2748 ``"musehub/orientation"``, ``"musehub/contribute"``.
2749 arguments: Optional dict of argument name → value to interpolate into
2750 the prompt body (e.g. ``{"caller_type": "agent"}`` for orientation,
2751 ``{"repo_id": "..."}`` for contribute).
2752
2753 Returns:
2754 ``MusehubToolResult`` with ``data.description`` (one-line summary) and
2755 ``data.messages`` (list of ``{role, content: {type, text}}`` dicts
2756 ready to inject into an agent's context window).
2757 Returns ``error_code="not_found"`` for unknown prompt names.
2758 """
2759 from musehub.mcp.prompts import PROMPT_NAMES, get_prompt
2760
2761 if name not in PROMPT_NAMES:
2762 available = sorted(PROMPT_NAMES)
2763 return MusehubToolResult(
2764 ok=False,
2765 error_code="invalid_args",
2766 error_message=(
2767 f"Unknown prompt '{name}'. "
2768 f"Available prompts: {', '.join(available)}"
2769 ),
2770 )
2771
2772 result = get_prompt(name, arguments)
2773 if result is None:
2774 return MusehubToolResult(
2775 ok=False,
2776 error_code="invalid_args",
2777 error_message=f"Prompt '{name}' could not be assembled.",
2778 )
2779
2780 messages: list[JSONValue] = [
2781 {
2782 "role": msg["role"],
2783 "content": msg["content"]["text"],
2784 }
2785 for msg in result["messages"]
2786 ]
2787
2788 return MusehubToolResult(
2789 ok=True,
2790 data={
2791 "name": name,
2792 "description": result["description"],
2793 "messages": messages,
2794 },
2795 )
2796
2797
2798 def execute_agent_notify(
2799 sender_session_id: str,
2800 sender_handle: str | None,
2801 target_handle: str,
2802 event: str,
2803 payload: JSONObject,
2804 ) -> MusehubToolResult:
2805 """Push a notifications/agent_message event to all sessions of target_handle."""
2806 from musehub.mcp.session import find_sessions_by_user, push_to_session
2807 from musehub.mcp.sse import sse_notification
2808 import datetime
2809
2810 targets = find_sessions_by_user(target_handle)
2811 if not targets:
2812 return MusehubToolResult(
2813 ok=False,
2814 error_code="not_ready",
2815 error_message=f"No active MCP sessions found for agent '{target_handle}'.",
2816 hint="Call musehub_read_coord_swarm() to see which agents are active.",
2817 )
2818
2819 params = {
2820 "from_agent": sender_handle or "anonymous",
2821 "from_session": f"{sender_session_id[:8]}...",
2822 "event": event,
2823 "payload": payload,
2824 "sent_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
2825 }
2826 event_text = sse_notification("notifications/agent_message", params)
2827
2828 delivered = 0
2829 for session in targets:
2830 push_to_session(session, event_text)
2831 delivered += 1
2832
2833 return MusehubToolResult(ok=True, data={
2834 "delivered": delivered,
2835 "target_handle": target_handle,
2836 "event": event,
2837 "sessions_reached": delivered,
2838 "message": f"Sent '{event}' to {delivered} session(s) for agent '{target_handle}'.",
2839 })
2840
2841
2842 def execute_agent_broadcast(
2843 sender_session_id: str,
2844 sender_handle: str | None,
2845 event: str,
2846 payload: JSONObject,
2847 repo_focus: tuple[str, str] | None,
2848 ) -> MusehubToolResult:
2849 """Push notifications/agent_message to all sessions focused on the same repo."""
2850 from musehub.mcp.session import find_sessions_by_repo, push_to_session
2851 from musehub.mcp.sse import sse_notification
2852 import datetime
2853
2854 if repo_focus is None:
2855 return MusehubToolResult(
2856 ok=False,
2857 error_code="missing_args",
2858 error_message="No repo focus set — cannot broadcast without a target repo.",
2859 hint="Call musehub_set_context(owner, slug) to focus the session on a repo first.",
2860 )
2861
2862 owner, slug = repo_focus
2863 candidates = find_sessions_by_repo(owner, slug)
2864 # Exclude the sender's own session.
2865 targets = [s for s in candidates if s.session_id != sender_session_id]
2866
2867 params = {
2868 "from_agent": sender_handle or "anonymous",
2869 "from_session": f"{sender_session_id[:8]}...",
2870 "event": event,
2871 "payload": payload,
2872 "repo": f"{owner}/{slug}",
2873 "sent_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
2874 }
2875 event_text = sse_notification("notifications/agent_message", params)
2876
2877 delivered = 0
2878 for session in targets:
2879 push_to_session(session, event_text)
2880 delivered += 1
2881
2882 return MusehubToolResult(ok=True, data={
2883 "delivered": delivered,
2884 "repo": f"{owner}/{slug}",
2885 "event": event,
2886 "sessions_reached": delivered,
2887 "excluded_self": True,
2888 "message": (
2889 f"Broadcast '{event}' to {delivered} session(s) on {owner}/{slug}."
2890 if delivered > 0
2891 else f"No other active sessions are focused on {owner}/{slug}."
2892 ),
2893 })
2894
2895
2896 async def execute_list_repo_forks(repo_id: str) -> MusehubToolResult:
2897 """List all direct forks of a repository.
2898
2899 Returns a flat list of fork entries ordered newest-first. Each entry
2900 includes the fork repo's full metadata plus ``sourceOwner`` and
2901 ``sourceSlug`` for attribution.
2902
2903 Args:
2904 repo_id: ID of the source repository.
2905
2906 Returns:
2907 ``MusehubToolResult`` with ``data.forks`` list and ``data.total``.
2908 """
2909 if (err := _check_db_available()) is not None:
2910 return err
2911
2912 async with AsyncSessionLocal() as session:
2913 from musehub.services import musehub_repository
2914 result = await musehub_repository.list_repo_forks_flat(session, repo_id)
2915
2916 return MusehubToolResult(ok=True, data={
2917 "total": result.total,
2918 "forks": [
2919 {
2920 "fork_id": e.fork_id,
2921 "fork_repo_id": e.fork_repo.repo_id,
2922 "fork_repo_name": e.fork_repo.name,
2923 "fork_repo_slug": e.fork_repo.slug,
2924 "fork_repo_owner": e.fork_repo.owner,
2925 "fork_repo_visibility": e.fork_repo.visibility,
2926 "source_owner": e.source_owner,
2927 "source_slug": e.source_slug,
2928 "forked_at": e.forked_at.isoformat(),
2929 }
2930 for e in result.forks
2931 ],
2932 })
2933
2934
2935 async def execute_get_fork_network(repo_id: str) -> MusehubToolResult:
2936 """Return the recursive fork network tree rooted at a repository.
2937
2938 The ``root`` node is the source repo; ``root.children`` are direct forks;
2939 each child's ``children`` list its own forks, and so on.
2940 ``totalForks`` is the flat count of all fork nodes (excluding root).
2941
2942 Args:
2943 repo_id: ID of the root repository.
2944
2945 Returns:
2946 ``MusehubToolResult`` with ``data.root`` and ``data.totalForks``.
2947 """
2948 if (err := _check_db_available()) is not None:
2949 return err
2950
2951 async with AsyncSessionLocal() as session:
2952 from musehub.services import musehub_repository
2953 network = await musehub_repository.list_repo_forks(session, repo_id)
2954
2955 from musehub.models.musehub import ForkNetworkNode
2956
2957 def _node_to_dict(node: ForkNetworkNode) -> JSONObject:
2958 return {
2959 "owner": node.owner,
2960 "repoSlug": node.repo_slug,
2961 "repoId": node.repo_id,
2962 "divergenceCommits": node.divergence_commits,
2963 "forkedBy": node.forked_by,
2964 "forkedAt": node.forked_at.isoformat() if node.forked_at else None,
2965 "children": [_node_to_dict(c) for c in node.children],
2966 }
2967
2968 return MusehubToolResult(ok=True, data={
2969 "root": _node_to_dict(network.root),
2970 "totalForks": network.total_forks,
2971 })
2972
2973
2974 async def execute_get_user_forks(username: str) -> MusehubToolResult:
2975 """Return all repositories a user has forked, with source attribution.
2976
2977 Ordered newest-first. Each entry includes full fork repo metadata plus
2978 ``sourceOwner``/``sourceSlug`` so callers can render
2979 "forked from {sourceOwner}/{sourceSlug}".
2980
2981 Args:
2982 username: MSign handle of the user.
2983
2984 Returns:
2985 ``MusehubToolResult`` with ``data.forks`` list and ``data.total``.
2986 """
2987 if (err := _check_db_available()) is not None:
2988 return err
2989
2990 async with AsyncSessionLocal() as session:
2991 from musehub.services import musehub_repository
2992 result = await musehub_repository.get_user_forks(session, username)
2993
2994 return MusehubToolResult(ok=True, data={
2995 "total": result.total,
2996 "forks": [
2997 {
2998 "fork_id": e.fork_id,
2999 "fork_repo_id": e.fork_repo.repo_id,
3000 "fork_repo_name": e.fork_repo.name,
3001 "fork_repo_slug": e.fork_repo.slug,
3002 "fork_repo_owner": e.fork_repo.owner,
3003 "fork_repo_visibility": e.fork_repo.visibility,
3004 "source_owner": e.source_owner,
3005 "source_slug": e.source_slug,
3006 "forked_at": e.forked_at.isoformat(),
3007 }
3008 for e in result.forks
3009 ],
3010 })
3011
3012
3013 # ── Issue comments ────────────────────────────────────────────────────────────
3014
3015
3016 async def execute_list_issue_comments(
3017 repo_id: str,
3018 issue_number: int,
3019 limit: int = 100,
3020 cursor: str | None = None,
3021 ) -> MusehubToolResult:
3022 """Return paginated comments for a single issue.
3023
3024 Comments are returned in chronological order (oldest first). Pass
3025 ``cursor`` (the ``nextCursor`` from a previous response) to advance the
3026 page.
3027
3028 Args:
3029 repo_id: ID of the repository that owns the issue.
3030 issue_number: Per-repo sequential issue number (e.g. 1, 2, 3 …).
3031 limit: Maximum comments to return per page (default 100).
3032 cursor: Opaque keyset cursor from a previous ``nextCursor`` field.
3033
3034 Returns:
3035 ``MusehubToolResult`` with ``data.comments`` list, ``data.total``,
3036 and ``data.next_cursor``.
3037 """
3038 if (err := _check_db_available()) is not None:
3039 return err
3040
3041 async with AsyncSessionLocal() as session:
3042 from musehub.services import musehub_issues
3043 issue = await musehub_issues.get_issue(session, repo_id, issue_number)
3044 if issue is None:
3045 return MusehubToolResult(
3046 ok=False,
3047 error_code="issue_not_found",
3048 error_message=f"Issue #{issue_number} not found in repo '{repo_id}'.",
3049 hint="Use musehub_list_issues() to find valid issue numbers.",
3050 )
3051
3052 result = await musehub_issues.list_comments(
3053 session,
3054 issue.issue_id,
3055 cursor=cursor,
3056 limit=limit,
3057 )
3058
3059 return MusehubToolResult(ok=True, data={
3060 "repo_id": repo_id,
3061 "issue_number": issue_number,
3062 "issue_id": issue.issue_id,
3063 "total": result.total,
3064 "next_cursor": result.next_cursor,
3065 "comments": [
3066 {
3067 "comment_id": c.comment_id,
3068 "issue_id": c.issue_id,
3069 "author": c.author,
3070 "body": c.body,
3071 "created_at": c.created_at.isoformat() if c.created_at else None,
3072 "updated_at": c.updated_at.isoformat() if c.updated_at else None,
3073 }
3074 for c in result.comments
3075 ],
3076 })
3077
3078
3079 # ── Release update + asset list ───────────────────────────────────────────────
3080
3081
3082 async def execute_update_release(
3083 repo_id: str,
3084 tag: str,
3085 title: str | None = None,
3086 body: str | None = None,
3087 channel: str | None = None,
3088 is_draft: bool | None = None,
3089 actor: str = "",
3090 ) -> MusehubToolResult:
3091 """Update mutable fields of an existing release.
3092
3093 Only the fields supplied (non-None) are changed; all others are left
3094 unchanged. The release is identified by its ``tag`` within ``repo_id``.
3095
3096 Args:
3097 repo_id: ID of the repository.
3098 tag: Semver tag of the release to update (e.g. ``"v1.2.3"``).
3099 title: New release title.
3100 body: New Markdown release notes.
3101 channel: New distribution channel label (e.g. ``"stable"``, ``"beta"``).
3102 is_draft: When ``True`` the release is hidden from the public listing.
3103 actor: Authenticated caller's MSign handle.
3104
3105 Returns:
3106 ``MusehubToolResult`` with updated release metadata in ``data``.
3107 """
3108 if (err := _check_db_available()) is not None:
3109 return err
3110
3111 async with AsyncSessionLocal() as session:
3112 from musehub.services import musehub_releases
3113 from sqlalchemy import select as _select
3114
3115 stmt = _select(MusehubRelease).where(
3116 MusehubRelease.repo_id == repo_id,
3117 MusehubRelease.tag == tag,
3118 )
3119 row = (await session.execute(stmt)).scalar_one_or_none()
3120 if row is None:
3121 return MusehubToolResult(
3122 ok=False,
3123 error_code="release_not_found",
3124 error_message=f"Release '{tag}' not found in repo '{repo_id}'.",
3125 hint="Use musehub_list_releases() to find valid release tags.",
3126 )
3127
3128 if title is not None:
3129 row.title = title
3130 if body is not None:
3131 row.body = body
3132 if channel is not None:
3133 row.channel = channel
3134 if is_draft is not None:
3135 row.is_draft = is_draft
3136
3137 session.add(row)
3138 await session.commit()
3139 await session.refresh(row)
3140 release = musehub_releases._to_release_response(row)
3141
3142 return MusehubToolResult(ok=True, data={
3143 "release_id": release.release_id,
3144 "repo_id": repo_id,
3145 "tag": release.tag,
3146 "title": release.title,
3147 "body": release.body,
3148 "channel": getattr(release, "channel", None),
3149 "is_prerelease": release.is_prerelease,
3150 "author": release.author,
3151 "created_at": release.created_at.isoformat() if release.created_at else None,
3152 })
3153
3154
3155 async def execute_list_release_assets(
3156 repo_id: str,
3157 tag: str,
3158 limit: int = 200,
3159 cursor: str | None = None,
3160 ) -> MusehubToolResult:
3161 """Return assets attached to a release, ordered oldest-first.
3162
3163 Args:
3164 repo_id: ID of the repository.
3165 tag: Version tag of the release (e.g. ``"v1.2.3"``).
3166 limit: Maximum assets to return (default 200).
3167 cursor: Opaque keyset cursor from a previous ``nextCursor`` field.
3168
3169 Returns:
3170 ``MusehubToolResult`` with ``data.assets`` list, ``data.total``, and
3171 ``data.next_cursor``.
3172 """
3173 if (err := _check_db_available()) is not None:
3174 return err
3175
3176 async with AsyncSessionLocal() as session:
3177 from musehub.services import musehub_releases
3178 release = await musehub_releases.get_release_by_tag(session, repo_id, tag)
3179 if release is None:
3180 return MusehubToolResult(
3181 ok=False,
3182 error_code="release_not_found",
3183 error_message=f"Release '{tag}' not found in repo '{repo_id}'.",
3184 hint="Use musehub_list_releases() to find valid release tags.",
3185 )
3186
3187 result = await musehub_releases.list_release_assets(
3188 session,
3189 release.release_id,
3190 tag,
3191 cursor=cursor,
3192 limit=limit,
3193 )
3194
3195 return MusehubToolResult(ok=True, data={
3196 "repo_id": repo_id,
3197 "release_id": release.release_id,
3198 "tag": tag,
3199 "total": result.total,
3200 "next_cursor": result.next_cursor,
3201 "assets": [
3202 {
3203 "asset_id": a.asset_id,
3204 "release_id": a.release_id,
3205 "name": a.name,
3206 "content_type": a.content_type,
3207 "size_bytes": a.size_bytes,
3208 "download_count": a.download_count,
3209 "download_url": a.download_url,
3210 "created_at": a.created_at.isoformat() if a.created_at else None,
3211 }
3212 for a in result.assets
3213 ],
3214 })
3215
3216
3217 # ── User profile ──────────────────────────────────────────────────────────────
3218
3219
3220 async def execute_read_user_profile(username: str) -> MusehubToolResult:
3221 """Return the public profile for a MuseHub user.
3222
3223 Args:
3224 username: MSign handle of the user (e.g. ``"gabriel"``).
3225
3226 Returns:
3227 ``MusehubToolResult`` with ``data`` containing the profile fields:
3228 ``username``, ``display_name``, ``bio``, ``avatar_url``,
3229 ``location``, ``website_url``, ``social_url``,
3230 ``pinned_repo_ids``, ``created_at``.
3231 """
3232 if (err := _check_db_available()) is not None:
3233 return err
3234
3235 async with AsyncSessionLocal() as session:
3236 from musehub.services import musehub_profile
3237 identity = await musehub_profile.get_profile_by_username(session, username)
3238 if identity is None:
3239 return MusehubToolResult(
3240 ok=False,
3241 error_code="user_not_found",
3242 error_message=f"User '{username}' not found.",
3243 hint="Check the username spelling. Handles are case-sensitive.",
3244 )
3245
3246 return MusehubToolResult(ok=True, data={
3247 "username": identity.handle,
3248 "display_name": identity.display_name or "",
3249 "bio": identity.bio or "",
3250 "avatar_url": identity.avatar_url or "",
3251 "location": identity.location or "",
3252 "website_url": identity.website_url or "",
3253 "social_url": identity.social_url or "",
3254 "pinned_repo_ids": list(identity.pinned_repo_ids or []),
3255 "created_at": identity.created_at.isoformat() if identity.created_at else None,
3256 })
3257
3258
3259 async def execute_update_user_profile(
3260 username: str,
3261 bio: str | None = None,
3262 avatar_url: str | None = None,
3263 pinned_repo_ids: list[str] | None = None,
3264 actor: str = "",
3265 ) -> MusehubToolResult:
3266 """Update mutable profile fields for a user.
3267
3268 Only the authenticated user may update their own profile (``actor`` must
3269 match ``username``). All fields are optional; omit to leave unchanged.
3270
3271 Args:
3272 username: MSign handle of the user to update.
3273 bio: Short Markdown bio (max 500 chars).
3274 avatar_url: URL of the profile picture.
3275 pinned_repo_ids: Up to 6 ``repo_id`` IDs to pin on the profile page.
3276 actor: Authenticated caller's MSign handle (must equal ``username``).
3277
3278 Returns:
3279 ``MusehubToolResult`` with updated profile data in ``data``.
3280 """
3281 if (err := _check_db_available()) is not None:
3282 return err
3283
3284 if actor and actor != username:
3285 return MusehubToolResult(
3286 ok=False,
3287 error_code="forbidden",
3288 error_message="You can only update your own profile.",
3289 hint=f"actor='{actor}' does not match username='{username}'.",
3290 )
3291
3292 async with AsyncSessionLocal() as session:
3293 from musehub.services import musehub_profile
3294 from musehub.models.musehub import ProfileUpdateRequest
3295 identity = await musehub_profile.get_profile_by_username(session, username)
3296 if identity is None:
3297 return MusehubToolResult(
3298 ok=False,
3299 error_code="user_not_found",
3300 error_message=f"User '{username}' not found.",
3301 hint="Check the username spelling. Handles are case-sensitive.",
3302 )
3303
3304 patch = ProfileUpdateRequest(
3305 bio=bio,
3306 avatar_url=avatar_url,
3307 pinned_repo_ids=pinned_repo_ids,
3308 )
3309 updated = await musehub_profile.update_profile(session, identity, patch)
3310 await session.commit()
3311
3312 return MusehubToolResult(ok=True, data={
3313 "username": updated.handle,
3314 "display_name": updated.display_name or "",
3315 "bio": updated.bio or "",
3316 "avatar_url": updated.avatar_url or "",
3317 "location": updated.location or "",
3318 "website_url": updated.website_url or "",
3319 "social_url": updated.social_url or "",
3320 "pinned_repo_ids": list(updated.pinned_repo_ids or []),
3321 "updated_at": updated.updated_at.isoformat() if updated.updated_at else None,
3322 })
3323
3324
3325 # ── Topics ────────────────────────────────────────────────────────────────────
3326
3327
3328 async def execute_list_topics(
3329 query: str | None = None,
3330 limit: int = 50,
3331 ) -> MusehubToolResult:
3332 """Return the most-used topic tags across all public repositories.
3333
3334 Aggregates the ``tags`` JSON array from all public repos and returns the
3335 top-N distinct tags ordered by frequency descending. Passing ``query``
3336 filters to tags that contain the substring (case-insensitive).
3337
3338 Args:
3339 query: Optional substring filter applied to tag names.
3340 limit: Maximum number of tags to return (default 50).
3341
3342 Returns:
3343 ``MusehubToolResult`` with ``data.topics`` list of
3344 ``{name, repo_count}`` objects, ordered by frequency.
3345 """
3346 if (err := _check_db_available()) is not None:
3347 return err
3348
3349 async with AsyncSessionLocal() as session:
3350 from sqlalchemy import select as _select
3351
3352 stmt = _select(MusehubRepo.tags).where(
3353 MusehubRepo.visibility == "public",
3354 MusehubRepo.tags.isnot(None),
3355 )
3356 rows = (await session.execute(stmt)).scalars().all()
3357
3358 counts: dict[str, int] = {}
3359 for tag_list in rows:
3360 for tag in (tag_list or []):
3361 if tag:
3362 counts[tag] = counts.get(tag, 0) + 1
3363
3364 if query:
3365 q_lower = query.lower()
3366 counts = {k: v for k, v in counts.items() if q_lower in k.lower()}
3367
3368 sorted_topics = sorted(counts.items(), key=lambda x: -x[1])[:limit]
3369
3370 return MusehubToolResult(ok=True, data={
3371 "total": len(sorted_topics),
3372 "topics": [{"name": name, "repo_count": count} for name, count in sorted_topics],
3373 })
3374
3375
3376 async def execute_set_repo_topics(
3377 repo_id: str,
3378 topics: list[str],
3379 actor: str = "",
3380 ) -> MusehubToolResult:
3381 """Replace the full topic list for a repository.
3382
3383 Overwrites the existing tags with the supplied list. Send an empty list
3384 to clear all topics. Topics are stored as the repo's ``tags`` JSON array.
3385
3386 Args:
3387 repo_id: ID of the repository.
3388 topics: Complete replacement list of topic strings.
3389 actor: Authenticated caller's MSign handle (must be repo owner or admin).
3390
3391 Returns:
3392 ``MusehubToolResult`` with ``data.topics`` containing the saved list.
3393 """
3394 if (err := _check_db_available()) is not None:
3395 return err
3396
3397 async with AsyncSessionLocal() as session:
3398 from musehub.services import musehub_repository
3399 from musehub.models.musehub import RepoSettingsPatch
3400 patch = RepoSettingsPatch(topics=topics)
3401 result = await musehub_repository.update_repo_settings(session, repo_id, patch)
3402 if result is None:
3403 return MusehubToolResult(
3404 ok=False,
3405 error_code="repo_not_found",
3406 error_message=f"Repository '{repo_id}' not found.",
3407 hint="Use musehub_search_repos() to find available repositories.",
3408 )
3409 await session.commit()
3410
3411 return MusehubToolResult(ok=True, data={
3412 "repo_id": repo_id,
3413 "topics": topics,
3414 })
3415
3416
3417 # ── Webhook deliveries ────────────────────────────────────────────────────────
3418
3419
3420 async def execute_list_webhook_deliveries(
3421 repo_id: str,
3422 webhook_id: str,
3423 limit: int = 50,
3424 cursor: str | None = None,
3425 ) -> MusehubToolResult:
3426 """Return delivery history for a webhook, newest-first.
3427
3428 Args:
3429 repo_id: ID of the repository (used for access guard).
3430 webhook_id: ID of the webhook whose deliveries to list.
3431 limit: Maximum deliveries per page (default 50).
3432 cursor: Opaque keyset cursor from a previous ``nextCursor`` field.
3433
3434 Returns:
3435 ``MusehubToolResult`` with ``data.deliveries`` list, ``data.total``,
3436 and ``data.next_cursor``.
3437 """
3438 if (err := _check_db_available()) is not None:
3439 return err
3440
3441 async with AsyncSessionLocal() as session:
3442 repo = await musehub_repository.get_repo(session, repo_id)
3443 if repo is None:
3444 return MusehubToolResult(
3445 ok=False,
3446 error_code="repo_not_found",
3447 error_message=f"Repository '{repo_id}' not found.",
3448 hint="Call musehub_search_repos() to find available repositories.",
3449 )
3450
3451 from musehub.services import musehub_webhook_dispatcher
3452 result = await musehub_webhook_dispatcher.list_deliveries(
3453 session, webhook_id, cursor=cursor, limit=limit
3454 )
3455
3456 return MusehubToolResult(ok=True, data={
3457 "repo_id": repo_id,
3458 "webhook_id": webhook_id,
3459 "total": result.total,
3460 "next_cursor": result.next_cursor,
3461 "deliveries": [
3462 {
3463 "delivery_id": d.delivery_id,
3464 "webhook_id": d.webhook_id,
3465 "event_type": d.event_type,
3466 "status_code": d.response_status,
3467 "success": d.success,
3468 "delivered_at": d.delivered_at.isoformat() if d.delivered_at else None,
3469 }
3470 for d in result.deliveries
3471 ],
3472 })
3473
3474
3475 async def execute_redeliver_webhook(
3476 repo_id: str,
3477 webhook_id: str,
3478 delivery_id: str,
3479 actor: str = "",
3480 ) -> MusehubToolResult:
3481 """Retry a previously attempted webhook delivery.
3482
3483 Re-sends the stored payload from ``delivery_id`` to the webhook's current
3484 URL. Each retry is persisted as a new delivery row; the original is not
3485 mutated.
3486
3487 Args:
3488 repo_id: ID of the repository (for access guard).
3489 webhook_id: ID of the webhook.
3490 delivery_id: ID of the original delivery to redeliver.
3491 actor: Authenticated caller's MSign handle.
3492
3493 Returns:
3494 ``MusehubToolResult`` with ``data.new_delivery_id`` of the new retry
3495 row and ``data.success`` indicating whether the retry succeeded.
3496 """
3497 if (err := _check_db_available()) is not None:
3498 return err
3499
3500 async with AsyncSessionLocal() as session:
3501 repo = await musehub_repository.get_repo(session, repo_id)
3502 if repo is None:
3503 return MusehubToolResult(
3504 ok=False,
3505 error_code="repo_not_found",
3506 error_message=f"Repository '{repo_id}' not found.",
3507 hint="Call musehub_search_repos() to find available repositories.",
3508 )
3509
3510 from musehub.services import musehub_webhook_dispatcher
3511 try:
3512 result = await musehub_webhook_dispatcher.redeliver_delivery(
3513 session, repo_id, webhook_id, delivery_id
3514 )
3515 await session.commit()
3516 except ValueError as exc:
3517 return MusehubToolResult(
3518 ok=False,
3519 error_code="delivery_not_found",
3520 error_message=str(exc),
3521 hint="Use musehub_list_webhook_deliveries() to find valid delivery IDs.",
3522 )
3523
3524 return MusehubToolResult(ok=True, data={
3525 "repo_id": repo_id,
3526 "webhook_id": webhook_id,
3527 "original_delivery_id": delivery_id,
3528 "new_delivery_id": result.new_delivery_id,
3529 "success": result.success,
3530 "status_code": result.response_status,
3531 "delivered_at": result.delivered_at.isoformat() if result.delivered_at else None,
3532 })
3533
3534
3535 # ── Mist read executors ───────────────────────────────────────────────────────
3536
3537
3538 async def execute_read_mist(
3539 mist_id: str,
3540 actor: str = "",
3541 ) -> MusehubToolResult:
3542 """Return the full wire representation of a single mist.
3543
3544 Public mists are readable by anyone. Secret mists require ``actor`` to be
3545 the mist owner — otherwise ``forbidden`` is returned.
3546
3547 Args:
3548 mist_id: 12-character content-addressed mist ID.
3549 actor: MSign handle of the caller; empty string = unauthenticated.
3550
3551 Returns:
3552 ``MusehubToolResult`` with full mist data on success.
3553 """
3554 if not mist_id:
3555 return MusehubToolResult(
3556 ok=False,
3557 error_code="missing_args",
3558 error_message="mist_id is required.",
3559 )
3560 if (err := _check_db_available()) is not None:
3561 return err
3562
3563 async with AsyncSessionLocal() as session:
3564 from musehub.services import musehub_mists
3565
3566 mist = await musehub_mists.get_mist(session, mist_id)
3567 if mist is None:
3568 return MusehubToolResult(
3569 ok=False,
3570 error_code="not_found",
3571 error_message=f"Mist '{mist_id}' not found.",
3572 hint="Call muse_mist_list(owner=...) to list available mists.",
3573 )
3574 if mist.visibility != "public" and mist.owner != actor:
3575 return MusehubToolResult(
3576 ok=False,
3577 error_code="forbidden",
3578 error_message="Secret mist — only the owner may read it.",
3579 hint="Authenticate as the mist owner to access secret mists.",
3580 )
3581
3582 await musehub_mists.increment_mist_view(session, mist_id)
3583 await session.commit()
3584
3585 return MusehubToolResult(
3586 ok=True,
3587 data={
3588 "mist_id": mist.mist_id,
3589 "url": mist.url,
3590 "owner": mist.owner,
3591 "artifact_type": mist.artifact_type,
3592 "language": mist.language,
3593 "filename": mist.filename,
3594 "title": mist.title,
3595 "description": mist.description,
3596 "content": mist.content,
3597 "size_bytes": mist.size_bytes,
3598 "version": mist.version,
3599 "signed": mist.signed,
3600 "agent_id": mist.agent_id,
3601 "model_id": mist.model_id,
3602 "fork_parent_id": mist.fork_parent_id,
3603 "fork_depth": mist.fork_depth,
3604 "fork_count": mist.fork_count,
3605 "view_count": mist.view_count,
3606 "embed_count": mist.embed_count,
3607 "visibility": mist.visibility,
3608 "tags": list(mist.tags),
3609 "symbol_anchors": list(mist.symbol_anchors),
3610 "created_at": mist.created_at.isoformat() if mist.created_at else None,
3611 "updated_at": mist.updated_at.isoformat() if mist.updated_at else None,
3612 },
3613 )
3614
3615
3616 async def execute_list_mists(
3617 owner: str | None = None,
3618 *,
3619 artifact_type: str | None = None,
3620 include_secret: bool = False,
3621 cursor: str | None = None,
3622 limit: int = 20,
3623 actor: str = "",
3624 ) -> MusehubToolResult:
3625 """Return mists for a handle (owner mode) or all public mists (explore mode).
3626
3627 When ``owner`` is ``None``, returns the global public discovery feed
3628 (newest first, secret mists excluded). When ``owner`` is set, returns that
3629 handle's mists; secret mists are included only when ``actor == owner``.
3630
3631 Args:
3632 owner: Handle to filter by; ``None`` for global explore.
3633 artifact_type: When set, restrict to this artifact type (e.g. ``"code"``).
3634 include_secret: Ignored unless ``actor == owner`` — only the owner can see
3635 their own secret mists.
3636 cursor: ISO-8601 ``created_at`` cursor from a previous response's
3637 ``next_cursor`` field.
3638 limit: Results per page (default 20, max 200).
3639 actor: MSign handle of the authenticated caller.
3640
3641 Returns:
3642 ``MusehubToolResult`` with ``data.mists`` list and optional ``data.next_cursor``.
3643 """
3644 if (err := _check_db_available()) is not None:
3645 return err
3646
3647 limit = max(1, min(limit, 200))
3648 effective_include_secret = include_secret and (owner is not None) and (actor == owner)
3649
3650 async with AsyncSessionLocal() as session:
3651 from musehub.services import musehub_mists
3652
3653 result = await musehub_mists.list_mists(
3654 session,
3655 owner=owner,
3656 artifact_type=artifact_type,
3657 include_secret=effective_include_secret,
3658 cursor=cursor,
3659 limit=limit,
3660 )
3661
3662 return MusehubToolResult(
3663 ok=True,
3664 data={
3665 "total": result.total,
3666 "next_cursor": result.next_cursor,
3667 "mists": [
3668 {
3669 "mist_id": m.mist_id,
3670 "owner": m.owner,
3671 "artifact_type": m.artifact_type,
3672 "language": m.language,
3673 "filename": m.filename,
3674 "title": m.title,
3675 "visibility": m.visibility,
3676 "fork_count": m.fork_count,
3677 "view_count": m.view_count,
3678 "tags": list(m.tags),
3679 "primary_symbol": m.primary_symbol,
3680 "created_at": m.created_at.isoformat() if m.created_at else None,
3681 }
3682 for m in result.mists
3683 ],
3684 },
3685 )
3686
3687
3688 async def execute_read_mist_embed(
3689 mist_id: str,
3690 owner: str,
3691 actor: str = "",
3692 ) -> MusehubToolResult:
3693 """Return embed codes for a mist (iframe, JavaScript snippet, badge).
3694
3695 Only public mists have embeddable codes. The embed count is incremented
3696 each time this tool is called so owners can track distribution.
3697
3698 Args:
3699 mist_id: 12-character mist ID.
3700 owner: Handle of the mist owner (used to build canonical URLs).
3701 actor: MSign handle of the caller (unused for public mists).
3702
3703 Returns:
3704 ``MusehubToolResult`` with ``iframe``, ``javascript``, and ``badge`` strings.
3705 """
3706 if not mist_id:
3707 return MusehubToolResult(
3708 ok=False,
3709 error_code="missing_args",
3710 error_message="mist_id is required.",
3711 )
3712 if not owner:
3713 return MusehubToolResult(
3714 ok=False,
3715 error_code="missing_args",
3716 error_message="owner is required.",
3717 )
3718 if (err := _check_db_available()) is not None:
3719 return err
3720
3721 async with AsyncSessionLocal() as session:
3722 from musehub.services import musehub_mists
3723
3724 mist = await musehub_mists.get_mist(session, mist_id)
3725 if mist is None:
3726 return MusehubToolResult(
3727 ok=False,
3728 error_code="not_found",
3729 error_message=f"Mist '{mist_id}' not found.",
3730 )
3731 if mist.visibility != "public":
3732 return MusehubToolResult(
3733 ok=False,
3734 error_code="forbidden",
3735 error_message="Embed codes are only available for public mists.",
3736 hint="Update the mist visibility to 'public' first.",
3737 )
3738
3739 await musehub_mists.increment_mist_embed(session, mist_id)
3740 await session.commit()
3741
3742 base = "https://musehub.ai"
3743 embed_url = f"{base}/{owner}/mists/{mist_id}/embed"
3744 badge_url = f"{base}/{owner}/mists/{mist_id}"
3745 return MusehubToolResult(
3746 ok=True,
3747 data={
3748 "mist_id": mist_id,
3749 "owner": owner,
3750 "embed_url": embed_url,
3751 "iframe": (
3752 f'<iframe src="{embed_url}" width="600" height="400" '
3753 f'frameborder="0" allowtransparency="true" '
3754 f'title="{mist.filename}"></iframe>'
3755 ),
3756 "javascript": (
3757 f'<script src="{base}/embed.js" '
3758 f'data-mist="{owner}/{mist_id}"></script>'
3759 ),
3760 "badge": (
3761 f'[![Mist]({base}/badge/{owner}/{mist_id}.svg)]({badge_url})'
3762 ),
3763 },
3764 )
3765
3766
3767 async def execute_list_mist_forks(
3768 mist_id: str,
3769 *,
3770 limit: int = 20,
3771 actor: str = "",
3772 ) -> MusehubToolResult:
3773 """Return the direct (one-level) forks of a mist, newest first.
3774
3775 Calls ``musehub_mists.get_mist_forks`` after verifying the parent mist
3776 exists and is readable by ``actor``. Secret parent mists require ``actor``
3777 to be the owner; public parents are accessible to everyone.
3778
3779 Args:
3780 mist_id: 12-character content-addressed ID of the parent mist.
3781 limit: Maximum number of forks to return (default 20, clamped to
3782 1–100).
3783 actor: MSign handle of the authenticated caller; empty string for
3784 anonymous callers.
3785
3786 Returns:
3787 ``MusehubToolResult`` with ``ok=True`` and ``data`` containing
3788 ``{"mist_id": str, "total": int, "forks": list}`` where each fork
3789 entry has ``mist_id``, ``owner``, ``filename``, ``artifact_type``,
3790 ``fork_depth``, ``fork_count``, ``visibility``, ``tags``,
3791 and ``created_at``. Returns ``ok=False`` with
3792 ``error_code="not_found"`` when the parent does not exist, or
3793 ``error_code="forbidden"`` when the parent is secret and ``actor``
3794 is not the owner.
3795
3796 Raises:
3797 No exceptions — all error conditions are returned as
3798 ``MusehubToolResult(ok=False, ...)``.
3799 """
3800 if not mist_id:
3801 return MusehubToolResult(
3802 ok=False,
3803 error_code="missing_args",
3804 error_message="mist_id is required.",
3805 )
3806 if (err := _check_db_available()) is not None:
3807 return err
3808
3809 limit = max(1, min(limit, 100))
3810
3811 async with AsyncSessionLocal() as session:
3812 from musehub.services import musehub_mists
3813
3814 parent = await musehub_mists.get_mist(session, mist_id)
3815 if parent is None:
3816 return MusehubToolResult(
3817 ok=False,
3818 error_code="not_found",
3819 error_message=f"Mist '{mist_id}' not found.",
3820 hint="Call muse_mist_list() to discover available mists.",
3821 )
3822 if parent.visibility != "public" and parent.owner != actor:
3823 return MusehubToolResult(
3824 ok=False,
3825 error_code="forbidden",
3826 error_message="Secret mist — only the owner may list its forks.",
3827 hint="Authenticate as the mist owner to access secret mists.",
3828 )
3829
3830 forks = await musehub_mists.get_mist_forks(session, mist_id, limit=limit)
3831 return MusehubToolResult(
3832 ok=True,
3833 data={
3834 "mist_id": mist_id,
3835 "total": len(forks),
3836 "forks": [
3837 {
3838 "mist_id": f.mist_id,
3839 "owner": f.owner,
3840 "filename": f.filename,
3841 "artifact_type": f.artifact_type,
3842 "fork_depth": f.fork_depth,
3843 "fork_count": f.fork_count,
3844 "visibility": f.visibility,
3845 "tags": list(f.tags),
3846 "created_at": f.created_at.isoformat() if f.created_at else None,
3847 }
3848 for f in forks
3849 ],
3850 },
3851 )
3852
3853
3854 async def execute_read_mist_raw(
3855 mist_id: str,
3856 *,
3857 actor: str = "",
3858 ) -> MusehubToolResult:
3859 """Return the raw artifact content of a mist as a plain string.
3860
3861 Useful for agents that need only the content bytes without the full
3862 metadata payload returned by ``execute_read_mist``. Increments the
3863 mist's view counter on success.
3864
3865 Secret mists require ``actor`` to match the mist owner. Public mists
3866 are readable by any caller, including anonymous (``actor=""``).
3867
3868 Args:
3869 mist_id: 12-character content-addressed mist identifier.
3870 actor: MSign handle of the authenticated caller; empty string
3871 for anonymous callers.
3872
3873 Returns:
3874 ``MusehubToolResult`` with ``ok=True`` and ``data`` containing
3875 ``{"mist_id": str, "filename": str, "artifact_type": str,
3876 "language": str | None, "size_bytes": int, "content": str}``.
3877 Returns ``ok=False`` with ``error_code="not_found"`` when the mist
3878 does not exist, or ``error_code="forbidden"`` when it is secret and
3879 ``actor`` is not the owner.
3880
3881 Raises:
3882 No exceptions — all error conditions are returned as
3883 ``MusehubToolResult(ok=False, ...)``.
3884 """
3885 if not mist_id:
3886 return MusehubToolResult(
3887 ok=False,
3888 error_code="missing_args",
3889 error_message="mist_id is required.",
3890 )
3891 if (err := _check_db_available()) is not None:
3892 return err
3893
3894 async with AsyncSessionLocal() as session:
3895 from musehub.services import musehub_mists
3896
3897 mist = await musehub_mists.get_mist(session, mist_id)
3898 if mist is None:
3899 return MusehubToolResult(
3900 ok=False,
3901 error_code="not_found",
3902 error_message=f"Mist '{mist_id}' not found.",
3903 hint="Call muse_mist_list() to discover available mists.",
3904 )
3905 if mist.visibility != "public" and mist.owner != actor:
3906 return MusehubToolResult(
3907 ok=False,
3908 error_code="forbidden",
3909 error_message="Secret mist — only the owner may read it.",
3910 hint="Authenticate as the mist owner to access secret mists.",
3911 )
3912
3913 await musehub_mists.increment_mist_view(session, mist_id)
3914 await session.commit()
3915
3916 return MusehubToolResult(
3917 ok=True,
3918 data={
3919 "mist_id": mist.mist_id,
3920 "filename": mist.filename,
3921 "artifact_type": mist.artifact_type,
3922 "language": mist.language,
3923 "size_bytes": mist.size_bytes,
3924 "content": mist.content,
3925 },
3926 )
3927
3928
3929 # ── Profile manifest + attestations + MPay ───────────────────────────────────
3930
3931
3932 async def execute_read_profile_manifest(handle: str) -> MusehubToolResult:
3933 """Return the full archetype-aware profile manifest for a MuseHub identity.
3934
3935 Unlike ``execute_read_user_profile`` (which returns only basic bio fields),
3936 this executor assembles the complete ``ProfileManifest``: activity canvas,
3937 attestation badges, AVAX address, agent trust chain, org manifest, and
3938 MPay ledger totals.
3939
3940 Args:
3941 handle: MSign handle of the identity (human, agent, or org).
3942
3943 Returns:
3944 ``MusehubToolResult`` with ``data`` containing the full
3945 ``ProfileManifest`` fields (snake_case keys).
3946 Returns ``ok=False`` with ``error_code="profile_not_found"`` when the
3947 handle does not exist.
3948 """
3949 if (err := _check_db_available()) is not None:
3950 return err
3951
3952 async with AsyncSessionLocal() as session:
3953 from musehub.services import (
3954 musehub_profile,
3955 musehub_attestations,
3956 musehub_mpay,
3957 )
3958
3959 attest_resp = await musehub_attestations.get_attestations_for_subject(
3960 session, handle
3961 )
3962 badges = [
3963 musehub_attestations.attestation_to_badge(a)
3964 for a in attest_resp.attestations
3965 ]
3966
3967 ledger = await musehub_mpay.get_mpay_ledger(session, handle)
3968
3969 manifest = await musehub_profile.build_profile_manifest(
3970 session,
3971 handle,
3972 attestations=badges,
3973 mpay_sent_nano=ledger.total_sent_nano,
3974 mpay_received_nano=ledger.total_received_nano,
3975 )
3976
3977 if manifest is None:
3978 return MusehubToolResult(
3979 ok=False,
3980 error_code="profile_not_found",
3981 error_message=f"Profile '{handle}' not found.",
3982 hint="Check the handle spelling — handles are case-sensitive.",
3983 )
3984
3985 return MusehubToolResult(ok=True, data=manifest.model_dump(mode="json"))
3986
3987
3988 async def execute_issue_attestation(
3989 attester: str,
3990 subject: str,
3991 claim: str,
3992 issued_at_iso: str,
3993 signature: str,
3994 attester_public_key: str,
3995 ) -> MusehubToolResult:
3996 """Verify an Ed25519 attestation signature and persist the attestation.
3997
3998 The caller must supply a valid signature over the canonical ATTEST message
3999 ``ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}`` signed with
4000 the attester's Ed25519 private key. The executor verifies the signature
4001 before writing to the database.
4002
4003 Args:
4004 attester: Handle of the identity issuing the attestation.
4005 subject: Handle of the identity being attested.
4006 claim: JSON claim payload (e.g. ``'{"type":"human"}'``).
4007 issued_at_iso: ISO-8601 timestamp of issuance.
4008 signature: Ed25519 signature; ``'ed25519:<base64url>'``.
4009 attester_public_key: Attester public key; ``'ed25519:<base64url>'``.
4010
4011 Returns:
4012 ``MusehubToolResult`` with ``ok=True`` and attestation fields on
4013 success. Returns ``error_code="invalid_attestation_signature"`` when
4014 the signature fails verification.
4015 """
4016 if (err := _check_db_available()) is not None:
4017 return err
4018
4019 from musehub.services import musehub_attestations
4020
4021 ok, reason = musehub_attestations.verify_attestation_signature(
4022 attester, subject, claim, issued_at_iso, signature, attester_public_key
4023 )
4024 if not ok:
4025 return MusehubToolResult(
4026 ok=False,
4027 error_code="invalid_attestation_signature",
4028 error_message=f"Signature verification failed: {reason}",
4029 hint="Recompute the signature over ATTEST\\n{attester}\\n{subject}\\n{claim}\\n{issued_at_iso}.",
4030 )
4031
4032 from datetime import datetime
4033 from musehub.models.musehub import AttestationRequest
4034
4035 async with AsyncSessionLocal() as session:
4036 resp = await musehub_attestations.issue_attestation(
4037 session,
4038 AttestationRequest(
4039 attester=attester,
4040 subject=subject,
4041 claim=claim,
4042 signature=signature,
4043 attester_public_key=attester_public_key,
4044 issued_at=datetime.fromisoformat(issued_at_iso),
4045 ),
4046 )
4047
4048 return MusehubToolResult(
4049 ok=True,
4050 data={
4051 "attestation_id": resp.attestation_id,
4052 "attester": resp.attester,
4053 "subject": resp.subject,
4054 "claim": resp.claim,
4055 "issued_at": resp.issued_at.isoformat(),
4056 "revoked_at": resp.revoked_at.isoformat() if resp.revoked_at else None,
4057 },
4058 )
4059
4060
4061 async def execute_revoke_attestation(
4062 attestation_id: str,
4063 revoker: str,
4064 ) -> MusehubToolResult:
4065 """Revoke an attestation.
4066
4067 Only the original attester may revoke their own attestation.
4068
4069 Args:
4070 attestation_id: ``sha256:``-prefixed attestation ID.
4071 revoker: MSign handle of the caller attempting to revoke.
4072
4073 Returns:
4074 ``MusehubToolResult`` with ``ok=True`` and ``revoked_at`` on success.
4075 Returns ``error_code="attestation_not_found"`` when the ID is unknown.
4076 Returns ``error_code="forbidden"`` when ``revoker`` is not the
4077 original attester.
4078 """
4079 if (err := _check_db_available()) is not None:
4080 return err
4081
4082 from musehub.services import musehub_attestations
4083
4084 async with AsyncSessionLocal() as session:
4085 try:
4086 resp = await musehub_attestations.revoke_attestation(
4087 session, attestation_id, revoker
4088 )
4089 except KeyError:
4090 return MusehubToolResult(
4091 ok=False,
4092 error_code="attestation_not_found",
4093 error_message=f"Attestation '{attestation_id}' not found.",
4094 )
4095 except PermissionError:
4096 return MusehubToolResult(
4097 ok=False,
4098 error_code="forbidden",
4099 error_message="Only the original attester may revoke this attestation.",
4100 )
4101
4102 return MusehubToolResult(
4103 ok=True,
4104 data={
4105 "attestation_id": resp.attestation_id,
4106 "attester": resp.attester,
4107 "subject": resp.subject,
4108 "claim": resp.claim,
4109 "issued_at": resp.issued_at.isoformat(),
4110 "revoked_at": resp.revoked_at.isoformat() if resp.revoked_at else None,
4111 },
4112 )
4113
4114
4115 async def execute_list_attestations(
4116 subject: str,
4117 include_revoked: bool = False,
4118 ) -> MusehubToolResult:
4119 """Return all attestations about a subject identity.
4120
4121 Args:
4122 subject: MSign handle of the identity whose attestations to list.
4123 include_revoked: When ``True``, include revoked attestations in the
4124 result. Defaults to ``False``.
4125
4126 Returns:
4127 ``MusehubToolResult`` with ``data`` containing ``subject``, ``total``,
4128 and ``attestations`` list.
4129 """
4130 if (err := _check_db_available()) is not None:
4131 return err
4132
4133 from musehub.services import musehub_attestations
4134
4135 async with AsyncSessionLocal() as session:
4136 resp = await musehub_attestations.get_attestations_for_subject(
4137 session, subject, include_revoked=include_revoked
4138 )
4139
4140 return MusehubToolResult(
4141 ok=True,
4142 data={
4143 "subject": resp.subject,
4144 "total": resp.total,
4145 "attestations": [
4146 {
4147 "attestation_id": a.attestation_id,
4148 "attester": a.attester,
4149 "subject": a.subject,
4150 "claim": a.claim,
4151 "issued_at": a.issued_at.isoformat(),
4152 "revoked_at": a.revoked_at.isoformat() if a.revoked_at else None,
4153 }
4154 for a in resp.attestations
4155 ],
4156 },
4157 )
4158
4159
4160 async def execute_record_mpay_claim(
4161 sender: str,
4162 recipient: str,
4163 amount_nano: int,
4164 nonce_hex: str,
4165 signature: str,
4166 sender_public_key: str,
4167 memo: str | None = None,
4168 ) -> MusehubToolResult:
4169 """Verify an Ed25519 MPay signature and persist the payment claim.
4170
4171 Validates that ``amount_nano > 0``, ``sender != recipient``, and the
4172 signature verifies over the canonical MPay message
4173 ``MPAY\\n{sender}\\n{recipient}\\n{amount_nano}\\n{nonce_hex}`` before
4174 writing to the database. The operation is idempotent on ``nonce_hex``.
4175
4176 Args:
4177 sender: Handle of the payer.
4178 recipient: Handle of the payee.
4179 amount_nano: Payment amount in nanoMUSE (must be > 0).
4180 nonce_hex: 64-char hex nonce (32 bytes) for replay protection.
4181 signature: Ed25519 signature; ``'ed25519:<base64url>'``.
4182 sender_public_key: Sender public key; ``'ed25519:<base64url>'``.
4183 memo: Optional payment memo (max 500 chars).
4184
4185 Returns:
4186 ``MusehubToolResult`` with ``ok=True`` and claim fields on success.
4187 Returns ``error_code="invalid_amount"`` when ``amount_nano <= 0``.
4188 Returns ``error_code="self_payment"`` when ``sender == recipient``.
4189 Returns ``error_code="invalid_mpay_signature"`` on signature failure.
4190 """
4191 if (err := _check_db_available()) is not None:
4192 return err
4193
4194 if amount_nano <= 0:
4195 return MusehubToolResult(
4196 ok=False,
4197 error_code="invalid_amount",
4198 error_message="amount_nano must be greater than zero.",
4199 )
4200 if sender == recipient:
4201 return MusehubToolResult(
4202 ok=False,
4203 error_code="self_payment",
4204 error_message="sender and recipient must be different handles.",
4205 )
4206
4207 from musehub.services import musehub_mpay
4208
4209 ok, reason = musehub_mpay.verify_mpay_signature(
4210 sender, recipient, amount_nano, nonce_hex, signature, sender_public_key
4211 )
4212 if not ok:
4213 return MusehubToolResult(
4214 ok=False,
4215 error_code="invalid_mpay_signature",
4216 error_message=f"MPay signature verification failed: {reason}",
4217 hint="Recompute the signature over MPAY\\n{sender}\\n{recipient}\\n{amount_nano}\\n{nonce_hex}.",
4218 )
4219
4220 from musehub.models.musehub import MPayClaimRequest
4221
4222 async with AsyncSessionLocal() as session:
4223 resp = await musehub_mpay.record_mpay_claim(
4224 session,
4225 MPayClaimRequest(
4226 sender=sender,
4227 recipient=recipient,
4228 amount_nano=amount_nano,
4229 nonce_hex=nonce_hex,
4230 signature=signature,
4231 sender_public_key=sender_public_key,
4232 memo=memo,
4233 ),
4234 )
4235
4236 return MusehubToolResult(
4237 ok=True,
4238 data={
4239 "claim_id": resp.claim_id,
4240 "sender": resp.sender,
4241 "recipient": resp.recipient,
4242 "amount_nano": resp.amount_nano,
4243 "nonce_hex": resp.nonce_hex,
4244 "memo": resp.memo,
4245 "created_at": resp.created_at.isoformat(),
4246 "confirmed_at": resp.confirmed_at.isoformat() if resp.confirmed_at else None,
4247 "voided_at": resp.voided_at.isoformat() if resp.voided_at else None,
4248 },
4249 )
4250
4251
4252 async def execute_get_mpay_ledger(
4253 handle: str,
4254 limit: int = 100,
4255 ) -> MusehubToolResult:
4256 """Return the MPay ledger for a handle — sent and received claims with totals.
4257
4258 Args:
4259 handle: MSign handle whose ledger to return.
4260 limit: Maximum number of claims to return per direction (clamped to
4261 500; default 100).
4262
4263 Returns:
4264 ``MusehubToolResult`` with ``data`` containing ``handle``,
4265 ``total_sent_nano``, ``total_received_nano``, ``sent``, and
4266 ``received`` lists.
4267 """
4268 if (err := _check_db_available()) is not None:
4269 return err
4270
4271 limit = min(limit, 500)
4272
4273 from musehub.models.musehub import MPayClaimResponse
4274 from musehub.services import musehub_mpay
4275
4276 async with AsyncSessionLocal() as session:
4277 ledger = await musehub_mpay.get_mpay_ledger(session, handle, limit=limit)
4278
4279 def _claim_dict(c: MPayClaimResponse) -> JSONObject:
4280 return {
4281 "claim_id": c.claim_id,
4282 "sender": c.sender,
4283 "recipient": c.recipient,
4284 "amount_nano": c.amount_nano,
4285 "nonce_hex": c.nonce_hex,
4286 "memo": c.memo,
4287 "created_at": c.created_at.isoformat(),
4288 "confirmed_at": c.confirmed_at.isoformat() if c.confirmed_at else None,
4289 "voided_at": c.voided_at.isoformat() if c.voided_at else None,
4290 }
4291
4292 return MusehubToolResult(
4293 ok=True,
4294 data={
4295 "handle": ledger.handle,
4296 "total_sent_nano": ledger.total_sent_nano,
4297 "total_received_nano": ledger.total_received_nano,
4298 "sent": [_claim_dict(c) for c in ledger.sent],
4299 "received": [_claim_dict(c) for c in ledger.received],
4300 },
4301 )
File History 1 commit
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 days ago