Skip to content

feat(serenity): split publish out of create + publish-after-populate finalize (LLMO-5492)#2584

Open
andreeastroe96 wants to merge 4 commits into
feat/LLMO-5201-5202-cohort-gate-marketsfrom
feat/LLMO-5492-push-prompts-publish-poll
Open

feat(serenity): split publish out of create + publish-after-populate finalize (LLMO-5492)#2584
andreeastroe96 wants to merge 4 commits into
feat/LLMO-5201-5202-cohort-gate-marketsfrom
feat/LLMO-5492-push-prompts-publish-poll

Conversation

@andreeastroe96

@andreeastroe96 andreeastroe96 commented Jun 10, 2026

Copy link
Copy Markdown

What & why

LLMO-5492 — T12 · push generated prompts + models to Semrush, publish-after-populate.

The onboarding fan-out (performSerenityFanOuthandleCreateMarket) published each Semrush project at create time — i.e. published an empty project. Prompts/models only live in our Postgres (llmo_customer_config) and were never pushed upstream.

⚠️ Stacked on feat/LLMO-5201-5202-cohort-gate-markets — review/merge that first. Base is set accordingly.

Behavior-preserving in prod: the new path is inert until SERENITY_DEFER_PUBLISH=true and the DRS-completion trigger calls finalizeSerenityProjects. Safe to merge but dormant until both land.

Scope (this PR)

AC#1 — split publish out of the create path (flag-gated)

  • SERENITY_DEFER_PUBLISH env flag + isSerenityDeferPublishEnabled(env)default OFF, so projects are never left unpublished until the finalize trigger is live.
  • handleCreateMarket / handleCreatePrompts take a { publish = true } option (default preserves the standalone-endpoint contract).
  • performSerenityFanOut passes publish: !deferPublish — provisions drafts when the flag is on.

AC#2 — publish-after-populate mechanism

  • New src/support/serenity/handlers/finalize.jsfinalizeSerenityProjects(...): push prompts (publish deferred) → set models per slice (handleUpdateModels) → publish each project exactly once.
  • Publish is gated on population: if prompts were requested but every push failed, publish is skipped so an empty project never goes live. Per-slice/per-project failures are recorded and don't abort the rest.
  • Model sync and publish are idempotent; prompt push is not dedup'd upstream, so the (future, separate-repo) trigger must deliver the prompt payload exactly once. Returns { prompts, models, published, publishFailed }.

AC#3 — publish-completion polling ✅ (now implemented)

Previously deferred as "blocked — rest-transport.js has no getProjectStatus." Unblocked once serenity-docs #12 (§6) confirmed the project carries a publish_status attribute (draft | publishing | initial_publish_failed | live | live_with_unpublished_updates).

  • rest-transport.js — new getProjectStatus(ws, pid)GET /v1/workspaces/{ws}/projects/{pid}. Uses the v1 default view deliberately (it echoes publish_status faithfully; the v2/live=true view empties a never-published draft's config per §10).
  • src/support/serenity/handlers/publish-status.js (new) — the reusable read/classify/poll mechanism. classifyPublishStatus maps live/live_with_unpublished_updates → published, initial_publish_failed → failed, draft/publishing/unknown → pending. pollProjectPublished(transport, ws, pid, { attempts, intervalMs, log, sleep }) bounds the poll; a status-read error is non-fatal (logged, retried, reported pending). The DRS/audit worker's unbounded ≤900s reconcile loop consumes this same helper.
  • finalize.js — after the async publish (202) is accepted, a bounded best-effort confirm within the Lambda's wall budget. Opt-in via typeof transport.getProjectStatus === 'function', so the existing 6-arg callers/tests are unchanged; new 7th options arg { confirmAttempts, confirmIntervalMs }. Confirmed live → published; initial_publish_failedpublishFailed (surfaced early); still draft/publishing within budget or status unreadable → stays published (accepted on 202; the worker reconciles) so an in-progress async publish is never mislabeled as failed.

Deferred / out of scope

  • AC#2 trigger ("when DRS prompt-generation completes") — lives in audit-worker/DRS, a separate repo.
  • The unbounded ≤900s reconcile poll — also the DRS/audit worker's job (separate repo, SQS-driven). This Lambda only does the bounded best-effort confirm within its ~15s wall budget; both consume the shared publish-status.js helper.
  • AC#4 (stop storing prompts in our DB) — deferred per the ticket.

Tests

  • New: flag helper (5), handleCreateMarket publish:false/default (2), handleCreatePrompts publish:false (1), finalizeSerenityProjects (publish-after-populate incl. empty-publish guard + partial-success, plus AC3 confirm cases: live→published, initial_publish_failed→publishFailed, publishing-within-budget→published, read-error→published, mixed), publish-status.js (read/classify + poll: first-read live, initial_publish_failed, draft→publishing→live, exhaust→pending, read-error→pending, single-attempt no-sleep), rest-transport getProjectStatus (URL + GET + raw JSON).
  • eslint clean. Code review + security review run (see Jira labels).

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

Andreea Stroe and others added 2 commits June 11, 2026 13:51
…finalize (LLMO-5492)

The onboarding fan-out published each Semrush project at create time — i.e.
published an EMPTY project. Prompts/models live only in our Postgres and were
never pushed upstream.

AC#1 — split publish out of the create path (flag-gated):
- add SERENITY_DEFER_PUBLISH env flag + isSerenityDeferPublishEnabled (default
  OFF, so projects are never left unpublished until the finalize trigger lands).
- handleCreateMarket / handleCreatePrompts take a `{ publish = true }` option
  (default preserves the standalone-endpoint contract).
- performSerenityFanOut passes publish:!deferPublish — drafts when the flag is on.

AC#2 — publish-after-populate mechanism:
- new finalizeSerenityProjects(): push prompts (publish deferred) + set models
  per slice + publish each project exactly once. Publish is gated on population
  — if prompts were requested but every push failed, publish is skipped so an
  empty project never goes live. Per-slice/per-project failures are recorded and
  don't abort the rest. The DRS-completion trigger that invokes it is deferred
  (audit-worker/DRS, a separate repo) and must deliver the prompt payload
  exactly once (prompt push is not dedup'd upstream; model sync and publish are
  idempotent).

AC#3 (completion polling) is blocked on Semrush exposing a per-project status
endpoint; AC#4 (stop storing prompts in our DB) is deferred per the ticket.

Behavior-preserving in prod: the new path is inert until SERENITY_DEFER_PUBLISH
is on AND the trigger calls finalizeSerenityProjects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…LMO-5492 AC3)

Implements AC3 (publish-completion polling), previously deferred because the
transport had no way to read a project's publish state.

- rest-transport: add getProjectStatus(ws, pid) -> GET /v1/workspaces/{ws}/
  projects/{pid} (v1 default view, which echoes publish_status faithfully;
  the v2/live=true view empties a never-published draft's config).
- handlers/publish-status.js (new): the reusable read/classify/poll mechanism
  built on the publish_status enum (draft | publishing | initial_publish_failed
  | live | live_with_unpublished_updates). classifyPublishStatus maps to
  published/failed/pending; pollProjectPublished bounds the poll by attempts/
  interval. The DRS/audit worker's unbounded <=900s reconcile loop consumes the
  same helper; finalize uses a tiny in-Lambda bound.
- finalize: after the async publish (202) is accepted, do a bounded best-effort
  confirm. Opt-in via `typeof transport.getProjectStatus === 'function'`, so the
  existing 6-arg callers/tests are unchanged; new 7th `options` arg
  {confirmAttempts, confirmIntervalMs}. Confirmed live -> published;
  initial_publish_failed -> publishFailed (surfaced early); still draft/
  publishing within budget or status unreadable -> stays published (accepted;
  the worker reconciles) so an in-progress async publish is not mislabeled.

Tests: rest-transport (getProjectStatus URL), publish-status (classify + poll),
finalize (confirm outcomes). 61 passing across the three suites; lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@andreeastroe96 andreeastroe96 force-pushed the feat/LLMO-5492-push-prompts-publish-poll branch from df6f629 to 372c939 Compare June 11, 2026 10:57
…ys-defer publish

Harden the Serenity onboarding publish/finalize flow against the official
Semrush "AI Project Setup" sequence (serenity-docs §9-10):

- Always defer publish: remove the SERENITY_DEFER_PUBLISH flag entirely. The
  onboarding fan-out unconditionally provisions drafts (publish: false); the
  single authoritative publish happens in finalize after prompts + models are
  pushed. Standalone POST /serenity/markets still publishes at create time.
- Gate publish on models, not just prompts: finalize now publishes a project
  only when at least one of its slices ended with >=1 model set. Zero models ->
  publishSkipped/noModels (logged); all-prompt-push-failed -> publishSkipped/
  noPrompts. Default-model policy is a trigger contract (documented): finalize
  never invents a fallback model set; the DRS-completion trigger owns body.models.
- Classify the zero-quota publish failure: a 405 + text/html on the publish route
  (no ai.projects allocation) is a PERMANENT allocation failure. rest-transport
  captures content-type onto SerenityTransportError; isPublishQuotaExhausted gates
  it. handleCreateMarket skips the best-effort deleteProject on a quota-405 (no
  create->405->delete loop) and finalize records it as permanent publishFailed.
- "published" means confirmed-live only: split the finalize return into
  published / publishPending (202 accepted but not confirmed in-budget; worker
  reconciles) / publishSkipped / publishFailed, so consumers never read an
  unconfirmed 202 as observed-live.
- Fix handleUpdateModels never republishing a live project: after a successful
  model diff it republishes IFF the project is currently live/
  live_with_unpublished_updates (best-effort), so model changes actually go live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A BrandSemrushProject row with an empty semrushProjectId (e.g. an orphan
slice left behind when create failed mid-onboarding) must be dropped from
the publish loop entirely — not published, not bucketed as skipped/failed.
Adds a finalize test exercising that branch, restoring 100% line/statement
coverage of finalize.js so codecov/patch clears the 99.99% target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant