The renderer existed but nothing called it. This wires it to the missions and puts the result somewhere a phone can reach. **A sweep, not a phase step.** Rendering is not the agents' work and must not be able to fail a phase that succeeded; a transient API error simply retries next tick, and a mission already rendered is skipped because its episode row exists. `podcast_episodes` is that record — without it the sweep would re-render on every pass and re-bill for it, the same lesson `corpus_items` taught for papers. **It is racing a reaper.** script.md lives in the mission checkout, and `mission_runtime`'s sweeper deletes that tree 30 minutes after the mission reaches a terminal state. So the sweep runs every 2 minutes, leaving ~15 attempts inside the window. When it does lose — as it did for three missions that had completed hours before this shipped — it now SAYS so and records a marker rather than skipping in silence, which is how a feed ends up quietly missing a day. The feed filters those markers out: a zero-byte enclosure shows a broken episode in a podcast app, where showing nothing is honest. **Duration is read from the audio, not estimated from the script.** The feed advertises a length and that length should be the real one — and it is the check that catches a 6 MB file playing for six seconds. **The feed authenticates by query-string token**, because no podcast app can set headers. That is a real trade: the token lands in the app's database and any proxy log. It reuses `AuthService::authenticate`, so revoking the session revokes the feed with it rather than creating a second secret to forget to rotate. Titles are XML-escaped — one raw ampersand makes a client reject the WHOLE feed, not one episode. 363 tests pass. Co-Authored-By: Claude Opus 5 <[email protected]>
39 lines
2.0 KiB
SQL
39 lines
2.0 KiB
SQL
-- One row per rendered episode.
|
|
--
|
|
-- The MP3 lives in blob storage; this is the index the feed reads. Without it
|
|
-- there is no way to answer "has this mission already been rendered?", and a
|
|
-- sweep that re-renders on every tick would spend real money each time — the
|
|
-- same lesson `corpus_items` taught for papers: a recurring job's hard problem
|
|
-- is knowing what it already did.
|
|
CREATE TABLE IF NOT EXISTS podcast_episodes (
|
|
id UUID PRIMARY KEY,
|
|
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
|
|
-- The mission whose script this came from. One episode per mission: a
|
|
-- mission is one day's research, and re-running it should replace the
|
|
-- episode rather than add a second one for the same material.
|
|
mission_id UUID NOT NULL REFERENCES missions (id) ON DELETE CASCADE,
|
|
UNIQUE (mission_id),
|
|
-- Vault date folder the script came from, e.g. 2026-08-18. Not derived from
|
|
-- created_at: a mission that runs past midnight belongs to the day it
|
|
-- harvested, not the day it finished rendering.
|
|
episode_date TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
-- Blob key for the MP3.
|
|
blob_key TEXT NOT NULL,
|
|
bytes BIGINT NOT NULL,
|
|
duration_secs INTEGER NOT NULL,
|
|
-- Which backend voiced it, for attribution when a future one sounds different.
|
|
rendered_by TEXT NOT NULL,
|
|
-- Hash of the script that produced this audio. A phase that re-runs and
|
|
-- rewrites its script must produce a NEW episode; an unchanged script must
|
|
-- not be re-rendered and re-billed.
|
|
script_sha TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS podcast_episodes_feed_idx
|
|
ON podcast_episodes (workspace_id, created_at DESC);
|
|
|
|
COMMENT ON TABLE podcast_episodes IS
|
|
'Rendered podcast episodes. The audio is in blob storage; this indexes it for the RSS feed and prevents re-rendering (and re-billing) work already done.';
|