diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index e08ad562a..0a9a8e659 100644 --- a/surfsense_backend/app/podcasts/api/routes.py +++ b/surfsense_backend/app/podcasts/api/routes.py @@ -221,6 +221,20 @@ async def regenerate_transcript( return PodcastDetail.of(podcast) +@router.post("/podcasts/{podcast_id}/regenerate/revert", response_model=PodcastDetail) +async def revert_regeneration( + podcast_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Back out of a regeneration and return to the finished episode.""" + podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE) + async with _lifecycle_errors(): + await PodcastService(session).revert_regeneration(podcast) + await session.commit() + return PodcastDetail.of(podcast) + + @router.post("/podcasts/{podcast_id}/cancel", response_model=PodcastDetail) async def cancel_podcast( podcast_id: int, diff --git a/surfsense_backend/app/podcasts/persistence/enums/podcast_status.py b/surfsense_backend/app/podcasts/persistence/enums/podcast_status.py index 61b0d72b3..28f29afb5 100644 --- a/surfsense_backend/app/podcasts/persistence/enums/podcast_status.py +++ b/surfsense_backend/app/podcasts/persistence/enums/podcast_status.py @@ -4,7 +4,9 @@ The status drives a guarded state machine. A podcast is proposed (``PENDING``), gets a reviewable brief (``AWAITING_BRIEF``), is drafted into a transcript (``DRAFTING``), then rendered to audio (``RENDERING`` → ``READY``). ``FAILED`` and ``CANCELLED`` are terminal; a ``READY`` episode can be sent back to the -brief gate for regeneration. ``AWAITING_REVIEW`` is retained for legacy rows but +brief gate for regeneration, and an in-flight regeneration can be reverted to +``READY`` while the previous audio still exists. ``AWAITING_REVIEW`` is +retained for legacy rows but never entered anymore — the brief is the only approval gate. The Python enum is kept in lockstep with the ``podcast_status`` Postgres type via its paired migration. diff --git a/surfsense_backend/app/podcasts/service.py b/surfsense_backend/app/podcasts/service.py index 89b36a2a8..8310a7228 100644 --- a/surfsense_backend/app/podcasts/service.py +++ b/surfsense_backend/app/podcasts/service.py @@ -21,11 +21,23 @@ _ALLOWED: dict[PodcastStatus, frozenset[PodcastStatus]] = { PodcastStatus.PENDING: frozenset( {PodcastStatus.AWAITING_BRIEF, PodcastStatus.FAILED, PodcastStatus.CANCELLED} ), + # The READY exits below exist for reverting a regeneration; the audio + # guard for that lives in revert_regeneration. PodcastStatus.AWAITING_BRIEF: frozenset( - {PodcastStatus.DRAFTING, PodcastStatus.FAILED, PodcastStatus.CANCELLED} + { + PodcastStatus.DRAFTING, + PodcastStatus.READY, + PodcastStatus.FAILED, + PodcastStatus.CANCELLED, + } ), PodcastStatus.DRAFTING: frozenset( - {PodcastStatus.RENDERING, PodcastStatus.FAILED, PodcastStatus.CANCELLED} + { + PodcastStatus.RENDERING, + PodcastStatus.READY, + PodcastStatus.FAILED, + PodcastStatus.CANCELLED, + } ), # Never entered anymore (the transcript gate was dropped); kept with exits # so legacy rows aren't stranded. @@ -140,6 +152,19 @@ class PodcastService: await self._session.flush() return podcast + async def revert_regeneration(self, podcast: Podcast) -> Podcast: + """Back out of a regeneration and fall back to the stored episode. + + Regeneration keeps the rendered audio until a new take replaces it, so + any point before that commit is a free change of mind. A fresh podcast + has no regeneration to revert and is rejected. + """ + if not _has_episode(podcast): + raise InvalidTransition("no finished episode to fall back to") + self._transition(podcast, PodcastStatus.READY) + await self._session.flush() + return podcast + async def attach_audio( self, podcast: Podcast, @@ -165,7 +190,15 @@ class PodcastService: return podcast async def cancel(self, podcast: Podcast) -> Podcast: - """Cancel a non-terminal podcast at the user's request.""" + """Cancel a podcast that has produced nothing the user could keep. + + No user action may destroy playable audio: once an episode exists, + backing out goes through revert_regeneration instead. + """ + if _has_episode(podcast): + raise InvalidTransition( + "a finished episode exists; revert the regeneration instead" + ) self._transition(podcast, PodcastStatus.CANCELLED) await self._session.flush() return podcast @@ -183,6 +216,11 @@ def _status(podcast: Podcast) -> PodcastStatus: return PodcastStatus(podcast.status) +def _has_episode(podcast: Podcast) -> bool: + """Whether finished audio is stored (``file_location`` covers legacy rows).""" + return bool(podcast.storage_key or podcast.file_location) + + def read_spec(podcast: Podcast) -> PodcastSpec | None: """Deserialize the stored brief, or ``None`` if not yet proposed.""" return PodcastSpec.model_validate(podcast.spec) if podcast.spec else None diff --git a/surfsense_backend/app/podcasts/tasks/render.py b/surfsense_backend/app/podcasts/tasks/render.py index dc8c9b7ed..8afe53b83 100644 --- a/surfsense_backend/app/podcasts/tasks/render.py +++ b/surfsense_backend/app/podcasts/tasks/render.py @@ -14,7 +14,12 @@ from pathlib import Path from app.celery_app import celery_app from app.podcasts.persistence import PodcastRepository from app.podcasts.rendering import PodcastRenderer -from app.podcasts.service import PodcastService, read_spec, read_transcript +from app.podcasts.service import ( + InvalidTransition, + PodcastService, + read_spec, + read_transcript, +) from app.podcasts.storage import purge_audio_object, store_audio from app.podcasts.tts import get_text_to_speech from app.podcasts.voices import get_voice_catalog @@ -65,10 +70,16 @@ async def _render_audio(podcast_id: int) -> dict: podcast_id=podcast_id, data=rendered.data, ) - await PodcastService(session).attach_audio( - podcast, storage_backend=backend_name, storage_key=key - ) - await session.commit() + try: + await PodcastService(session).attach_audio( + podcast, storage_backend=backend_name, storage_key=key + ) + await session.commit() + except InvalidTransition: + # A user back-out won the race (e.g. the regeneration was + # reverted): drop the stale render and leave the row alone. + await purge_audio_object(key) + return {"status": "superseded", "podcast_id": podcast_id} # Purge only after the new audio is committed, so a failed re-render never # destroys the episode the user can still play. diff --git a/surfsense_backend/tests/integration/podcasts/test_cancel.py b/surfsense_backend/tests/integration/podcasts/test_cancel.py index fb5239e95..e4d78031d 100644 --- a/surfsense_backend/tests/integration/podcasts/test_cancel.py +++ b/surfsense_backend/tests/integration/podcasts/test_cancel.py @@ -1,8 +1,9 @@ -"""Cancelling a podcast: allowed while in flight, refused once terminal. +"""Cancelling a podcast: allowed while in flight, refused once an episode exists. -Cancellation is a user escape hatch from any non-terminal state; a podcast that -has already finished (READY) has no exit, so the disallowed transition surfaces -as 409. +Cancellation is the escape hatch for a podcast that has produced nothing yet. +Once a finished episode exists — including during a regeneration, whose audio +survives until a new render commits — cancel is refused (409): reverting the +regeneration is the way back, and no user action may destroy playable audio. """ import pytest @@ -37,3 +38,22 @@ async def test_cancel_from_a_terminal_state_conflicts( resp = await client.post(f"{BASE}/{podcast.id}/cancel") assert resp.status_code == 409 + + +async def test_cancel_of_a_regeneration_is_rejected( + client, db_search_space, make_podcast +): + # Cancelling here would destroy a playable episode; reverting the + # regeneration is the way back. + podcast = await make_podcast( + search_space_id=db_search_space.id, status=PodcastStatus.READY + ) + await client.post(f"{BASE}/{podcast.id}/transcript/regenerate") + + resp = await client.post(f"{BASE}/{podcast.id}/cancel") + + assert resp.status_code == 409 + # The regeneration is still revertable afterwards. + follow_up = await client.post(f"{BASE}/{podcast.id}/regenerate/revert") + assert follow_up.status_code == 200 + assert follow_up.json()["status"] == "ready" diff --git a/surfsense_backend/tests/integration/podcasts/test_regeneration.py b/surfsense_backend/tests/integration/podcasts/test_regeneration.py index 7c2118dfb..8617c3d37 100644 --- a/surfsense_backend/tests/integration/podcasts/test_regeneration.py +++ b/surfsense_backend/tests/integration/podcasts/test_regeneration.py @@ -2,9 +2,11 @@ A user who dislikes the finished audio sends the episode back to the brief gate: the saved brief reopens for tweaks (voices, length, focus) and drafting -only restarts on a fresh approval. These pin the READY -> AWAITING_BRIEF -> -DRAFTING round trip and the 409 for regenerating from states that have nothing -to redo. +only restarts on a fresh approval. The whole redo can also be reverted at any +point before the new render commits, falling back to the still-stored episode. +These pin the READY -> AWAITING_BRIEF -> DRAFTING round trip, the revert +fallback, and the 409s for acting from states that have nothing to redo or +revert. """ from __future__ import annotations @@ -12,6 +14,9 @@ from __future__ import annotations import pytest from app.podcasts.persistence import PodcastStatus +from app.podcasts.service import PodcastService + +from .conftest import build_transcript pytestmark = pytest.mark.integration @@ -78,3 +83,99 @@ async def test_regenerate_from_cancelled_is_rejected( assert resp.status_code == 409 assert captured_tasks.draft == [] + + +async def test_reverting_a_regeneration_restores_the_ready_episode( + client, db_search_space, make_podcast, captured_tasks +): + podcast = await make_podcast( + search_space_id=db_search_space.id, status=PodcastStatus.READY + ) + await client.post(f"{BASE}/{podcast.id}/transcript/regenerate") + + resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert") + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ready" + # The episode the user could already play is untouched. + assert body["has_audio"] is True + assert captured_tasks.draft == [] + assert captured_tasks.render == [] + + +async def test_reverting_mid_draft_keeps_the_episode( + client, db_search_space, make_podcast +): + # Changing one's mind is allowed even after the reopened brief was + # approved: the episode survives until a new render replaces it. + podcast = await make_podcast( + search_space_id=db_search_space.id, status=PodcastStatus.READY + ) + await client.post(f"{BASE}/{podcast.id}/transcript/regenerate") + await client.post(f"{BASE}/{podcast.id}/brief/approve") + + resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert") + + assert resp.status_code == 200 + assert resp.json()["status"] == "ready" + + +async def test_reverting_mid_render_keeps_the_episode( + client, db_session, db_search_space, make_podcast +): + podcast = await make_podcast( + search_space_id=db_search_space.id, status=PodcastStatus.READY + ) + service = PodcastService(db_session) + await service.regenerate(podcast) + await service.begin_drafting(podcast) + await service.attach_transcript(podcast, build_transcript()) + + resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert") + + assert resp.status_code == 200 + assert resp.json()["status"] == "ready" + + +async def test_reverted_episode_can_be_regenerated_again( + client, db_search_space, make_podcast +): + # Reverting must not strand the episode: the user can change their mind + # again immediately. + podcast = await make_podcast( + search_space_id=db_search_space.id, status=PodcastStatus.READY + ) + await client.post(f"{BASE}/{podcast.id}/transcript/regenerate") + await client.post(f"{BASE}/{podcast.id}/regenerate/revert") + + resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate") + + assert resp.status_code == 200 + assert resp.json()["status"] == "awaiting_brief" + + +async def test_revert_on_a_fresh_brief_gate_is_rejected( + client, db_search_space, make_podcast +): + # A first-time brief has no regeneration to revert. + podcast = await make_podcast( + search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF + ) + + resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert") + + assert resp.status_code == 409 + assert resp.json()["detail"] + + +async def test_revert_when_nothing_was_regenerated_is_rejected( + client, db_search_space, make_podcast +): + podcast = await make_podcast( + search_space_id=db_search_space.id, status=PodcastStatus.READY + ) + + resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert") + + assert resp.status_code == 409 diff --git a/surfsense_backend/tests/integration/podcasts/test_render_task.py b/surfsense_backend/tests/integration/podcasts/test_render_task.py index c93314aa4..5a97a00c7 100644 --- a/surfsense_backend/tests/integration/podcasts/test_render_task.py +++ b/surfsense_backend/tests/integration/podcasts/test_render_task.py @@ -64,3 +64,37 @@ async def test_rerender_replaces_audio_and_purges_the_old_object( assert podcast.storage_key != old_key assert fake_storage.objects[podcast.storage_key] == b"merged-audio" assert old_key in fake_storage.deleted + + +async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothing( + db_session, + db_search_space, + make_podcast, + bind_task_session, + fake_tts, + fake_merge, + fake_storage, +): + # The user reverts the regeneration while the render is in flight: the + # stale render must neither resurrect the redo nor leak the object it + # already stored. + podcast = await make_podcast( + search_space_id=db_search_space.id, status=PodcastStatus.READY + ) + old_key = podcast.storage_key + fake_storage.objects[old_key] = b"old-audio" + + service = PodcastService(db_session) + await service.regenerate(podcast) + await service.begin_drafting(podcast) + await service.attach_transcript(podcast, build_transcript()) + await service.revert_regeneration(podcast) + + result = await render._render_audio(podcast.id) + + assert result["status"] == "superseded" + assert podcast.status == PodcastStatus.READY + assert podcast.storage_key == old_key + assert old_key not in fake_storage.deleted + stale_keys = [key for key in fake_storage.objects if key != old_key] + assert all(key in fake_storage.deleted for key in stale_keys) diff --git a/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx b/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx index 41fc34bb4..f881be9dd 100644 --- a/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx @@ -1,24 +1,46 @@ "use client"; import type { ToolCallMessagePartProps } from "@assistant-ui/react"; -import { Loader2, RotateCcw } from "lucide-react"; +import { Loader2, RotateCcw, Undo2, X } from "lucide-react"; import { usePathname } from "next/navigation"; -import { useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { toast } from "sonner"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; -import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button, buttonVariants } from "@/components/ui/button"; import { type LivePodcast, usePodcastLive } from "@/hooks/use-podcast-live"; import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { BriefReview } from "./brief-review"; import { PodcastErrorState, PodcastPlayer } from "./player"; import type { GeneratePodcastArgs, GeneratePodcastResult } from "./schema"; -function WorkingState({ title, label }: { title: string; label: string }) { +function WorkingState({ + title, + label, + action, +}: { + title: string; + label: string; + action?: ReactNode; +}) { return (
{title}
-{title}
+{title}
-- Confirm the language, voices, and length — the episode generates automatically after - you approve. -
+{title}
++ Confirm the language, voices, and length — the episode generates automatically after + you approve. +
+