gabriel / musehub public
musehub_mists.py python
635 lines 21.4 KB
Raw
sha256:3707eba7ad42cadedf18c8b9c534d839b88cfd1c30924c3c5a3edc74e1d809de feat: add url field to mist, issue, and proposal list/read … Sonnet 4.6 minor ⚠ breaking 5 days ago
1 """Service layer for Muse Mists — persistence and query logic.
2
3 All public functions accept an :class:`~sqlalchemy.ext.asyncio.AsyncSession`
4 and return Pydantic wire models from :mod:`musehub.models.mists`. Callers
5 are responsible for committing the session — service functions only flush.
6
7 Session contract
8 ----------------
9 - ``session.add(obj)`` + ``await session.flush()`` to insert.
10 - ``await session.refresh(obj)`` after flush to reload server-set defaults.
11 - Attribute mutation + ``await session.flush()`` to update.
12 - ``await session.delete(obj)`` + ``await session.flush()`` to delete.
13 - Atomic counter increments use ``UPDATE ... SET col = col + 1`` via
14 ``sqlalchemy.update`` to avoid SELECT + race conditions.
15
16 Pagination
17 ----------
18 All list functions use keyset (cursor) pagination rather than LIMIT/OFFSET.
19 The cursor is the ISO-8601 ``created_at`` of the last row seen — pass it
20 verbatim as the ``cursor`` argument. Each response includes a ``next_cursor``
21 field; when it is ``None``, the caller is on the last page.
22
23 Public functions
24 ----------------
25 :func:`create_mist` Persist a new mist and return its wire representation.
26 :func:`get_mist` Fetch a single mist by mist_id.
27 :func:`list_mists` Cursor-paginated list for a handle (or global explore).
28 :func:`fork_mist` Create a forked copy of an existing mist.
29 :func:`update_mist` Patch title, description, visibility, tags, or content.
30 :func:`delete_mist` Hard-delete; returns False when not found or not owner.
31 :func:`increment_mist_view` Atomically increment view_count.
32 :func:`increment_mist_embed` Atomically increment embed_count.
33 """
34
35 import logging
36 from datetime import datetime, timezone
37
38 from sqlalchemy import ColumnElement, func, select, update
39 from sqlalchemy.ext.asyncio import AsyncSession
40
41 from musehub.db.musehub_repo_models import MusehubMist
42 from musehub.models.mists import (
43 MistForkResponse,
44 MistListEntry,
45 MistListResponse,
46 MistResponse,
47 )
48
49 logger = logging.getLogger(__name__)
50
51 # ---------------------------------------------------------------------------
52 # Internal helpers
53 # ---------------------------------------------------------------------------
54
55 _FORK_DEPTH_LIMIT = 5
56
57 def _utc_now() -> datetime:
58 """Return the current time in UTC with timezone info.
59
60 Returns:
61 Current UTC datetime with tzinfo set.
62 """
63 return datetime.now(tz=timezone.utc)
64
65 def _to_mist_response(row: MusehubMist, *, base_url: str = "") -> MistResponse:
66 """Convert a ``MusehubMist`` ORM row to a :class:`MistResponse` wire model.
67
68 Args:
69 row: The ORM row to convert.
70 base_url: Server base URL for constructing the canonical ``url`` field
71 (e.g. ``https://musehub.ai``). Empty string produces a
72 relative path — used in tests without a live server.
73
74 Returns:
75 A fully-populated :class:`MistResponse`.
76 """
77 url = f"{base_url}/{row.owner}/mists/{row.mist_id}" if base_url else ""
78 return MistResponse(
79 mist_id=row.mist_id,
80 url=url,
81 owner=row.owner,
82 artifact_type=row.artifact_type,
83 language=row.language,
84 filename=row.filename,
85 title=row.title,
86 description=row.description,
87 content=row.content,
88 size_bytes=row.size_bytes,
89 commit_id=row.commit_id,
90 snapshot_id=row.snapshot_id,
91 version=row.version,
92 signed=bool(row.gpg_signature),
93 agent_id=row.agent_id,
94 model_id=row.model_id,
95 fork_parent_id=row.fork_parent_id,
96 fork_depth=row.fork_depth,
97 fork_count=row.fork_count,
98 view_count=row.view_count,
99 embed_count=row.embed_count,
100 visibility=row.visibility,
101 tags=list(row.tags or []),
102 symbol_anchors=list(row.symbol_anchors or []),
103 created_at=row.created_at,
104 updated_at=row.updated_at,
105 )
106
107 def _to_mist_list_entry(row: MusehubMist, *, base_url: str = "") -> MistListEntry:
108 """Convert a ``MusehubMist`` ORM row to a condensed :class:`MistListEntry`.
109
110 The ``primary_symbol`` field is set to the first entry in
111 ``symbol_anchors`` — callers use it for the list-page symbol preview
112 without reading the full anchor list.
113
114 Args:
115 row: The ORM row to convert.
116 base_url: Server base URL for constructing the canonical ``url`` field
117 (e.g. ``https://musehub.ai``). Empty string produces ``""``.
118
119 Returns:
120 A condensed :class:`MistListEntry` for list/explore page display.
121 """
122 anchors: list[str] = list(row.symbol_anchors or [])
123 url = f"{base_url}/{row.owner}/mists/{row.mist_id}" if base_url else ""
124 return MistListEntry(
125 mist_id=row.mist_id,
126 url=url,
127 owner=row.owner,
128 artifact_type=row.artifact_type,
129 language=row.language,
130 filename=row.filename,
131 title=row.title,
132 size_bytes=row.size_bytes,
133 version=row.version,
134 signed=bool(row.gpg_signature),
135 agent_id=row.agent_id,
136 model_id=row.model_id,
137 fork_parent_id=row.fork_parent_id,
138 fork_depth=row.fork_depth,
139 fork_count=row.fork_count,
140 view_count=row.view_count,
141 visibility=row.visibility,
142 tags=list(row.tags or []),
143 primary_symbol=anchors[0] if anchors else None,
144 created_at=row.created_at,
145 updated_at=row.updated_at,
146 )
147
148 async def _count_mists(
149 session: AsyncSession,
150 conditions: list[ColumnElement[bool]],
151 ) -> int:
152 """Return the total number of mists matching all given conditions.
153
154 Args:
155 session: Active async DB session.
156 conditions: SQLAlchemy WHERE clause conditions.
157
158 Returns:
159 Total row count as an integer.
160 """
161 stmt = select(func.count(MusehubMist.mist_id)).where(*conditions)
162 return (await session.execute(stmt)).scalar_one()
163
164 # ---------------------------------------------------------------------------
165 # Public service functions
166 # ---------------------------------------------------------------------------
167
168 async def create_mist(
169 session: AsyncSession,
170 *,
171 mist_id: str,
172 filename: str,
173 content: str,
174 owner: str,
175 repo_id: str,
176 artifact_type: str = "unknown",
177 language: str = "",
178 size_bytes: int = 0,
179 title: str = "",
180 description: str = "",
181 visibility: str = "public",
182 tags: list[str] | None = None,
183 agent_id: str = "",
184 model_id: str = "",
185 gpg_signature: str | None = None,
186 commit_id: str | None = None,
187 snapshot_id: str | None = None,
188 symbol_anchors: list[str] | None = None,
189 base_url: str = "",
190 ) -> MistResponse:
191 """Persist a new mist and return its wire representation.
192
193 The ``mist_id`` is pre-computed by the caller (typically via
194 ``compute_mist_id(content_bytes)`` from the mist plugin). This function
195 does not re-derive the ID — it trusts the caller's value and enforces the
196 primary-key uniqueness constraint at the DB level.
197
198 Args:
199 session: Active async DB session (caller commits).
200 mist_id: 12-character base-58 content address.
201 filename: Sanitised original filename.
202 content: Artifact content (UTF-8 or base64 for binary).
203 owner: MSign handle of the mist owner.
204 repo_id: ID of the underlying Muse repo (domain="mist").
205 artifact_type: Detected artifact type (default: "unknown").
206 language: Detected language (empty for non-code).
207 size_bytes: Byte length of the original artifact.
208 title: Optional human-readable title.
209 description: Optional Markdown description.
210 visibility: ``"public"`` or ``"secret"`` (default: "public").
211 tags: Freeform tags (max 10).
212 agent_id: MSign agent identifier for AI-authored mists.
213 model_id: Model identifier for AI provenance.
214 gpg_signature: ASCII-armoured Ed25519 signature.
215 commit_id: Muse commit ID of the initial version.
216 snapshot_id: Object store ID of the initial snapshot.
217 symbol_anchors: Cached symbol addresses for code mists.
218 base_url: Server root URL for the canonical ``url`` field.
219
220 Returns:
221 The newly created mist as a :class:`MistResponse`.
222
223 Raises:
224 sqlalchemy.exc.IntegrityError: When the mist_id already exists.
225 """
226 row = MusehubMist(
227 mist_id=mist_id,
228 repo_id=repo_id,
229 owner=owner,
230 artifact_type=artifact_type,
231 language=language,
232 filename=filename,
233 title=title,
234 description=description,
235 content=content,
236 size_bytes=size_bytes,
237 commit_id=commit_id,
238 snapshot_id=snapshot_id,
239 version=1,
240 agent_id=agent_id,
241 model_id=model_id,
242 gpg_signature=gpg_signature,
243 fork_parent_id=None,
244 fork_depth=0,
245 fork_count=0,
246 view_count=0,
247 embed_count=0,
248 visibility=visibility,
249 tags=tags or [],
250 symbol_anchors=symbol_anchors or [],
251 )
252 session.add(row)
253 await session.flush()
254 await session.refresh(row)
255
256 logger.info(
257 "✅ Created mist %s for owner %s (type=%s, size=%d bytes)",
258 mist_id,
259 owner,
260 artifact_type,
261 size_bytes,
262 )
263 return _to_mist_response(row, base_url=base_url)
264
265 async def get_mist(
266 session: AsyncSession,
267 mist_id: str,
268 *,
269 base_url: str = "",
270 ) -> MistResponse | None:
271 """Fetch a single mist by its content-addressed ID.
272
273 Args:
274 session: Active async DB session.
275 mist_id: 12-character base-58 mist ID.
276 base_url: Server root URL for the canonical ``url`` field.
277
278 Returns:
279 A :class:`MistResponse` when found, or ``None`` when not found.
280 """
281 row: MusehubMist | None = await session.get(MusehubMist, mist_id)
282 if row is None:
283 return None
284 return _to_mist_response(row, base_url=base_url)
285
286 async def list_mists(
287 session: AsyncSession,
288 owner: str | None = None,
289 *,
290 artifact_type: str | None = None,
291 include_secret: bool = False,
292 cursor: str | None = None,
293 limit: int = 20,
294 base_url: str = "",
295 ) -> MistListResponse:
296 """Return mists for a handle (or all public mists) with keyset pagination.
297
298 When ``owner`` is None, all public mists are returned (explore mode).
299 When ``owner`` is set, only that handle's mists are returned; secret
300 mists are included only when ``include_secret`` is True.
301
302 Pagination
303 ----------
304 The cursor is the ISO-8601 ``created_at`` of the last row seen (taken
305 verbatim from ``next_cursor`` in the previous response). Pass ``None``
306 to start from the most recent mists.
307
308 Args:
309 session: Active async DB session.
310 owner: Handle to filter by; ``None`` for global explore.
311 artifact_type: When set, restrict results to this artifact type.
312 include_secret: Include secret mists (only for the owner themselves).
313 cursor: Pagination cursor (ISO-8601 ``created_at`` of last row).
314 limit: Maximum mists per page (default 20, max enforced by caller).
315 base_url: Server base URL for constructing canonical ``url`` fields
316 in each :class:`MistListEntry` (e.g. ``https://musehub.ai``).
317
318 Returns:
319 A :class:`MistListResponse` with the current page and ``next_cursor``.
320 """
321 conditions: list[ColumnElement[bool]] = []
322
323 if owner is not None:
324 conditions.append(MusehubMist.owner == owner)
325
326 if not include_secret:
327 conditions.append(MusehubMist.visibility == "public")
328
329 if artifact_type is not None:
330 conditions.append(MusehubMist.artifact_type == artifact_type)
331
332 total = await _count_mists(session, conditions)
333
334 data_conditions = list(conditions)
335 if cursor is not None:
336 try:
337 cursor_dt = datetime.fromisoformat(cursor)
338 except ValueError:
339 cursor_dt = None
340 if cursor_dt is not None:
341 data_conditions.append(MusehubMist.created_at < cursor_dt)
342
343 rows = list(
344 (
345 await session.execute(
346 select(MusehubMist)
347 .where(*data_conditions)
348 .order_by(MusehubMist.created_at.desc())
349 .limit(limit + 1)
350 )
351 ).scalars()
352 )
353
354 next_cursor: str | None = None
355 if len(rows) == limit + 1:
356 next_cursor = rows[limit - 1].created_at.isoformat()
357 rows = rows[:limit]
358
359 return MistListResponse(
360 total=total,
361 next_cursor=next_cursor,
362 mists=[_to_mist_list_entry(r, base_url=base_url) for r in rows],
363 )
364
365 async def fork_mist(
366 session: AsyncSession,
367 mist_id: str,
368 *,
369 new_mist_id: str,
370 new_owner: str,
371 new_repo_id: str,
372 base_url: str = "",
373 ) -> MistForkResponse | None:
374 """Create a forked copy of an existing mist.
375
376 The fork gets a fresh content-addressed ``mist_id`` (same content →
377 same mist_id as the parent, so the fork_mist_id is derived from a
378 slightly different payload — callers must pre-compute it and pass it in).
379
380 The fork's ``fork_parent_id`` points to the original, and the original's
381 ``fork_count`` is atomically incremented.
382
383 Fork depth is enforced: returns ``None`` when the source mist is already
384 at depth :data:`_FORK_DEPTH_LIMIT`.
385
386 Args:
387 session: Active async DB session.
388 mist_id: mist_id of the mist to fork.
389 new_mist_id: Pre-computed mist_id for the fork.
390 new_owner: Handle of the fork owner.
391 new_repo_id: ID of the Muse repo created for the fork.
392 base_url: Server root URL for the canonical ``url`` field.
393
394 Returns:
395 A :class:`MistForkResponse` for the new fork, or ``None`` when the
396 source mist is not found or the fork depth limit is exceeded.
397 """
398 source: MusehubMist | None = await session.get(MusehubMist, mist_id)
399 if source is None:
400 return None
401
402 if source.fork_depth >= _FORK_DEPTH_LIMIT:
403 logger.warning(
404 "Fork depth limit (%d) reached for mist %s — rejecting fork",
405 _FORK_DEPTH_LIMIT,
406 mist_id,
407 )
408 return None
409
410 fork_row = MusehubMist(
411 mist_id=new_mist_id,
412 repo_id=new_repo_id,
413 owner=new_owner,
414 artifact_type=source.artifact_type,
415 language=source.language,
416 filename=source.filename,
417 title=source.title,
418 description=source.description,
419 content=source.content,
420 size_bytes=source.size_bytes,
421 commit_id=source.commit_id,
422 snapshot_id=source.snapshot_id,
423 version=1,
424 agent_id="",
425 model_id="",
426 gpg_signature=None,
427 fork_depth=source.fork_depth + 1,
428 fork_count=0,
429 view_count=0,
430 embed_count=0,
431 visibility=source.visibility,
432 tags=list(source.tags or []),
433 symbol_anchors=list(source.symbol_anchors or []),
434 )
435 # MappedAsDataclass initialises init=False fields (fork_parent=None) after
436 # init=True fields, causing the ORM to sync fork_parent_id → None during
437 # __init__. Set fork_parent_id after construction so it isn't overwritten.
438 # Set fork_parent_id after add (not in constructor) to avoid MappedAsDataclass
439 # init=False relationship defaults overwriting the FK column during __init__.
440 # Use the relationship object so SQLAlchemy knows the parent is already
441 # persistent. Setting only fork_parent_id (the FK column) leaves
442 # fork_parent=None, which causes SQLAlchemy to INSERT with fork_parent_id=NULL
443 # (self-referential adjacency-list pattern) and never update it.
444 fork_row.fork_parent = source
445 session.add(fork_row)
446
447 # Atomically increment the source's fork_count.
448 await session.execute(
449 update(MusehubMist)
450 .where(MusehubMist.mist_id == mist_id)
451 .values(fork_count=MusehubMist.fork_count + 1),
452 )
453
454 await session.flush()
455 await session.refresh(fork_row)
456
457 url = f"{base_url}/{new_owner}/mists/{new_mist_id}" if base_url else ""
458 logger.info(
459 "✅ Forked mist %s → %s (owner=%s, depth=%d)",
460 mist_id,
461 new_mist_id,
462 new_owner,
463 fork_row.fork_depth,
464 )
465 return MistForkResponse(
466 mist_id=new_mist_id,
467 url=url,
468 owner=new_owner,
469 fork_parent_id=mist_id,
470 artifact_type=fork_row.artifact_type,
471 language=fork_row.language,
472 filename=fork_row.filename,
473 created_at=fork_row.created_at,
474 )
475
476 async def update_mist(
477 session: AsyncSession,
478 mist_id: str,
479 requesting_owner: str,
480 *,
481 title: str | None = None,
482 description: str | None = None,
483 visibility: str | None = None,
484 tags: list[str] | None = None,
485 filename: str | None = None,
486 content: str | None = None,
487 base_url: str = "",
488 ) -> MistResponse | None:
489 """Patch a mist's metadata and optionally its content.
490
491 Only the mist owner can update. Fields that are ``None`` are left
492 unchanged — this is a true partial update.
493
494 When ``content`` is provided the artifact is replaced and ``version`` is
495 incremented atomically.
496
497 Args:
498 session: Active async DB session.
499 mist_id: mist_id of the mist to update.
500 requesting_owner: Handle of the caller — must match the mist owner.
501 title: New title; ``None`` to leave unchanged.
502 description: New Markdown description; ``None`` to leave unchanged.
503 visibility: New visibility; ``None`` to leave unchanged.
504 tags: New tag list; ``None`` to leave unchanged.
505 content: New artifact content; ``None`` to leave unchanged.
506 base_url: Server root URL for the canonical ``url`` field.
507
508 Returns:
509 The updated :class:`MistResponse`, or ``None`` when the mist is not
510 found or the caller is not the owner.
511 """
512 row: MusehubMist | None = await session.get(MusehubMist, mist_id)
513 if row is None or row.owner != requesting_owner:
514 return None
515
516 if title is not None:
517 row.title = title
518 if description is not None:
519 row.description = description
520 if visibility is not None:
521 row.visibility = visibility
522 if tags is not None:
523 row.tags = tags
524 if filename is not None:
525 row.filename = filename
526 if content is not None:
527 row.content = content
528 row.size_bytes = len(content.encode("utf-8"))
529 row.version = row.version + 1
530
531 row.updated_at = _utc_now()
532 await session.flush()
533 await session.refresh(row)
534
535 logger.info("✅ Updated mist %s (owner=%s)", mist_id, requesting_owner)
536 return _to_mist_response(row, base_url=base_url)
537
538 async def delete_mist(
539 session: AsyncSession,
540 mist_id: str,
541 requesting_owner: str,
542 ) -> bool:
543 """Hard-delete a mist and its underlying Muse repo.
544
545 Only the mist owner can delete. Deleting via ``session.delete()`` triggers
546 the ``CASCADE`` on the ``musehub_repos`` FK — the underlying repo (and all
547 its commits, branches, and snapshots) is removed automatically.
548
549 Args:
550 session: Active async DB session.
551 mist_id: mist_id of the mist to delete.
552 requesting_owner: Handle of the caller — must match the mist owner.
553
554 Returns:
555 ``True`` when the mist was deleted, ``False`` when not found or the
556 caller is not the owner.
557 """
558 row: MusehubMist | None = await session.get(MusehubMist, mist_id)
559 if row is None:
560 return False
561 if row.owner != requesting_owner:
562 return False
563
564 await session.delete(row)
565 await session.flush()
566
567 logger.info("🗑️ Deleted mist %s (owner=%s)", mist_id, requesting_owner)
568 return True
569
570 async def increment_mist_view(
571 session: AsyncSession,
572 mist_id: str,
573 ) -> None:
574 """Atomically increment the view count for a mist.
575
576 Uses ``UPDATE ... SET view_count = view_count + 1`` to avoid SELECT +
577 UPDATE races under concurrent requests. Silently no-ops when the mist
578 does not exist (e.g. race between view and delete).
579
580 Args:
581 session: Active async DB session.
582 mist_id: mist_id to increment.
583 """
584 await session.execute(
585 update(MusehubMist)
586 .where(MusehubMist.mist_id == mist_id)
587 .values(view_count=MusehubMist.view_count + 1)
588 )
589
590 async def increment_mist_embed(
591 session: AsyncSession,
592 mist_id: str,
593 ) -> None:
594 """Atomically increment the embed count for a mist.
595
596 Uses ``UPDATE ... SET embed_count = embed_count + 1`` to avoid SELECT +
597 UPDATE races under concurrent embed loads.
598
599 Args:
600 session: Active async DB session.
601 mist_id: mist_id to increment.
602 """
603 await session.execute(
604 update(MusehubMist)
605 .where(MusehubMist.mist_id == mist_id)
606 .values(embed_count=MusehubMist.embed_count + 1)
607 )
608
609 async def get_mist_forks(
610 session: AsyncSession,
611 mist_id: str,
612 *,
613 limit: int = 20,
614 ) -> list[MistListEntry]:
615 """Return the direct forks of a mist (one level deep).
616
617 Args:
618 session: Active async DB session.
619 mist_id: mist_id whose forks to list.
620 limit: Maximum number of forks to return.
621
622 Returns:
623 A list of :class:`MistListEntry` for each direct fork.
624 """
625 rows = list(
626 (
627 await session.execute(
628 select(MusehubMist)
629 .where(MusehubMist.fork_parent_id == mist_id)
630 .order_by(MusehubMist.created_at.desc())
631 .limit(limit)
632 )
633 ).scalars()
634 )
635 return [_to_mist_list_entry(r) for r in rows]
File History 2 commits
sha256:3707eba7ad42cadedf18c8b9c534d839b88cfd1c30924c3c5a3edc74e1d809de feat: add url field to mist, issue, and proposal list/read … Sonnet 4.6 minor 5 days ago
sha256:aaf8bf405c0538f98feeff2a703d5e17e7e17565b56d73c5e711d57766086e78 confirm updating primary file in mist is surfaced in GUI. Human minor 11 days ago