Publish Service
File:src/cofounder_agent/services/publish_service.py
Tested by: src/cofounder_agent/tests/unit/services/test_publish_service.py
Last reviewed: 2026-04-30
What it does
publish_post_from_task() is the ONE place where a completed
pipeline_tasks row becomes a row in posts. It runs from three
entry points (the /approve endpoint with auto_publish=True, the
explicit /publish endpoint, and the auto_publish_gate atom in the
content pipeline — TaskExecutor was deleted 2026-05-16; its
_auto_publish_task was ported to services/auto_publish.py)
and handles everything that should happen exactly once when a post
goes live: parse merged result+metadata, extract title from content,
slugify, resolve author + category + tags, insert the posts row,
update the task to published, emit a post.published webhook, then
fan out a long list of fire-and-forget side effects.
Side effects (each gated by feature toggles or local-mode checks):
sync to cloud DB, embed into pgvector, queue social posts,
cross-post to Dev.to, ISR revalidation on Vercel, static JSON export
to R2, IndexNow + Google sitemap pings, podcast episode generation,
video episode generation, short-form video, R2 media upload + RSS
regen, YouTube upload, newsletter blast, OpenClaw notification.
The pacing scheduler (_calculate_scheduled_publish_time) is opt-in
via honor_pacing=True. Default is immediate publish because the
human reviewer is already the throttle.
Public API
await publish_post_from_task(db_service, task, task_id, *, publisher="operator", trigger_revalidation=True, queue_social=True, draft_mode=False, honor_pacing=False, background_tasks=None) -> PublishResult— the single canonical entry point.PublishResult(success, post_id, post_slug, published_url, post_title, revalidation_success, error)— return value withto_dict()for HTTP responses.
_calculate_scheduled_publish_time(db_service)— returnsNone(publish now) or a future UTCdatetime. Readsmax_posts_per_daypublish_spacing_hoursfromapp_settings.
Configuration
All fromapp_settings via site_config:
site_url(REQUIRED, no default —site_config.require()raises if missing) — used for IndexNow + sitemap pings + YouTube descriptions.indexnow_key(default"") — IndexNow ping API key. Empty key still sends the ping; settingindexnow_ping_url=""disables.indexnow_ping_url(defaulthttps://api.indexnow.org/indexnow) — set to""to skip IndexNow entirely.google_sitemap_ping_url(defaulthttps://www.google.com/ping) — set to""to skip Google sitemap ping.internal_api_base_url(default =DEFAULT_WORKER_API_URL) — used to fetch the live podcast/video RSS feeds before R2 upload.short_video_post_publish_delay_seconds(default180) — wait before generating the short video so the long-form podcast/video finish first.media_upload_delay_seconds(default240) — wait before uploading media to the object-store CDN so generators have time to finish. (Storage-agnostic rename of the deprecatedmedia_r2_upload_delay_seconds, #731.)max_posts_per_day(default3, only whenhonor_pacing=True).publish_spacing_hours(default4, only whenhonor_pacing=True).
LOCAL_DATABASE_URL— presence flips on the local-mode side effects (cloud sync, podcast/video gen, R2 upload, newsletter). This is one of the ~8 legitimate env vars per GH#93. See_should_run_post_publish_hooks().
Dependencies
- Reads from:
pipeline_tasks(the task arg, plus the existing-slug guard)tagstable (resolved tag rows forpost_tagsjunction)services.category_resolver.select_category_for_topicservices.default_author.get_or_create_default_authorsite_config(from AppContainer or DI) for IndexNow + URL settingsutils.text_utils.extract_title_from_content(LLM# Titlelift)
- Writes to:
posts(the central INSERT)tags(upserts each new term —ON CONFLICT (slug) DO UPDATE)post_tags(viadb_service.create_post’stag_idshandling)pipeline_tasks(status →published, result JSON updated)webhook_eventsindirectly viaemit_webhook_event("post.published", ...)(the helper’s actual target — earlier docs miscalled itpipeline_events; that unrelated table was dropped 2026-05-04 in poindexter#366)audit_logindirectly via the[content_published]log line
- External APIs (all fire-and-forget, errors swallowed):
- Vercel ISR (
trigger_nextjs_revalidation) - IndexNow + Google sitemap ping
- Cloudflare R2 / S3 (
upload_to_r2,upload_podcast_episode,upload_video_episode) - Dev.to (
DevToCrossPostService) - Media distribution — YouTube / Postiz via the
publishing_adapterssurface (services/jobs/media_distribute.py) - Internal worker API (
internal_api_base_url) for RSS regen - Newsletter delivery (
send_post_newsletter) - Telegram/Discord via
_notify_openclaw
- Vercel ISR (
Failure modes
- Missing content or topic — short-circuits with
PublishResult(success=False, error="Missing content or topic — cannot create post"). - Duplicate task already published — slug-suffix idempotency guard
finds an existing post with
slug LIKE '%' || task_id[:8]. ReturnsPublishResult(success=True, ...)pointing at the original. Does not insert a duplicate. Visible in logs asPost already exists for task ... — skipping duplicate. db_service.create_postraises — returnsPublishResult(success=False, error="Failed to create post: ..."). No fire-and-forget side effects fire.- Side-effect failure — every fire-and-forget block is wrapped in
try/except Exceptionand logged at debug or warning. Publish succeeds even if every side effect fails. This is intentional; losing a sitemap ping must not block a post going live. site_urlnot set —site_config.require("site_url")raisesRuntimeErrorBEFORE the search-engine pings would run. The post is already in the DB at that point, but the function will bubble the error and revalidation/notification step won’t complete. Setsite_urlinapp_settingsbefore publishing.- ISR revalidation failure — non-fatal;
revalidation_success=Falsecomes back on thePublishResultand is logged as a warning.
Common ops
- Force a duplicate publish — change the
task_id(the idempotency guard keys ontask_id[:8]in the slug suffix). - Disable social fan-out for one publish: pass
queue_social=False. - Publish as draft (skip live distribution side effects): pass
draft_mode=True. The post lands asstatus='draft',distributed_atstays NULL. - Schedule pacing for a backlog: pass
honor_pacing=Trueand tunemax_posts_per_day+publish_spacing_hours. Otherwise the human reviewer is the throttle. - Re-trigger ISR for a stuck cache:
await trigger_nextjs_revalidation(["/posts/<slug>"], ["post:<slug>"])via the FastAPI shell or a one-off script. - Audit recent publishes:
SELECT id, slug, published_at, status FROM posts ORDER BY published_at DESC LIMIT 20;
See also
docs/architecture/services/content_router_service.md— upstream pipeline that produced the task.docs/operations/disaster-recovery.md— cleanup steps when a publish goes wrong.feedback_no_bulk_publish(operator design note) — Matt’s rule that bulk publishes never bypass per-post approval.