Skip to content

feat(onboarding): T3 · Semrush workspace check + project fan-out + 200/207 (LLMO-5203/5204/5205)#2516

Open
andreeastroe96 wants to merge 4 commits into
feat/LLMO-5201-5202-cohort-gate-marketsfrom
feat/LLMO-5203-serenity-flag-client
Open

feat(onboarding): T3 · Semrush workspace check + project fan-out + 200/207 (LLMO-5203/5204/5205)#2516
andreeastroe96 wants to merge 4 commits into
feat/LLMO-5201-5202-cohort-gate-marketsfrom
feat/LLMO-5203-serenity-flag-client

Conversation

@andreeastroe96
Copy link
Copy Markdown

@andreeastroe96 andreeastroe96 commented May 29, 2026

Summary

Implements the full T3 chain of the Semrush onboarding orchestrator (epic LLMO-5007) inside performLlmoOnboarding, gated by the SERENITY_SITE_ALLOWLIST cohort gate:

Step Ticket What
M5 T3a / LLMO-5203 Fail fast (404) on missing Semrush workspace
M7 T3b / LLMO-5204 Fan out one Semrush project per (market, language) tuple
M8 T3c / LLMO-5205 Read back DB state, shape 200/207 response

⚠️ Stacked PR. Base is feat/LLMO-5201-5202-cohort-gate-markets (PR #2513), not main. Merge/rebase after #2513 lands.


M5 — fail fast on missing workspace (LLMO-5203)

Cohort org with no semrushWorkspaceId404 + operator-actionable message. Hoisted to right after createOrFindOrganization (before any site/brand state) so a 404 leaves nothing behind and "retry onboarding" works — per the design doc §M5 rationale. 404 not 412; no rollback. Implemented via a status:404/preflight:true throw mapped to notFound in the controller; cleanup catch skips rollback for pre-flight throws.

M7 — fan out per market tuple (LLMO-5204)

performSerenityFanOut calls the shipped AIO Proxy handler handleCreateProject in-process, sequentially, one tuple at a time. Best-effort (§M7): per-tuple failures are collected, never abort the rest; 409 = idempotent success. Keyed by the brand UUID (captured from upsertBrand) + the M5-validated workspace; the IMS bearer is forwarded to Semrush. Missing/non-IMS bearer, missing brand row, or unbuildable transport → recorded as per-tuple failures, not an onboarding failure.

M8 — read back + shape 200/207 (LLMO-5205)

reconcileSerenityProjects reads BrandSemrushProject.allByBrandId(brandId) as the authoritative source — not the fan-out's own responses — so it also catches the "proxy returned 201 but the row insert failed silently" case. A requested tuple with a matching DB row is succeeded; one without is failed (enriched with the fan-out's status/error, else projectRowMissing). The controller returns 200 when all tuples are present, 207 Multi-Status when any failed; both carry the standard onboarding fields plus requested/succeeded/failed. Successful tuples are never rolled back; re-invoking with the same markets[] is safe (proxy 409-dedupes).

Tests

  • M5: fail-fast (404, preflight, no Site.create); controller 404 mapping; existing allowlist test binds a workspace.
  • M7: 9 unit tests for performSerenityFanOut (201 + body contract, 409 idempotency, partial failure, thrown transport error, no bearer, non-IMS auth, missing brand, transport-build failure, empty markets).
  • M8: 6 unit tests for reconcileSerenityProjects (empty/no-brand short-circuit, all-present, missing tuple enriched from fan-out error, silently-missing row → projectRowMissing, read-back throws → fallback) + 2 controller tests (207 on partial failure, 200 when all succeed).
  • llmo-api.yaml: 404 documented; 207 clarified to mirror the 200 body + arrays.

Note: the suite was not run locally — this checkout can't npm ci here (needs Node 24; only Node 26 available, plus an unfetchable private git dep). Files pass node --check, OpenAPI YAML parses, no max-len issues. CI / a Node-24 env must run npm test + npm run docs:lint.

Heads-up for @luis6156

Hoists the serenityEnabled cohort-gate evaluation to the M1/M2 position (matches T1/5201's "at the top" wording; PR #2513 placed it after upsertBrand). The M6–M8 block reuses the same boolean — reconcile between branches.

🤖 Generated with Claude Code

Implements step M5 of the Semrush onboarding orchestrator (epic LLMO-5007).
When an org is in the SERENITY_SITE_ALLOWLIST cohort but has no Semrush
workspace bound, onboarding now fails fast with a 404 and an operator-
actionable message instead of proceeding into the provisioning path.

Per the orchestration design (LLMO-5007 §M5), the check is hoisted to run
immediately after createOrFindOrganization — before any other Adobe-side
state (site, entitlement, audits, brand) is created — so a missing workspace
leaves nothing behind and the operator can bind one via
PATCH /organizations/:id and retry cleanly. The cohort gate (serenityEnabled)
is evaluated once here and reused by the later M6-M8 block.

- llmo-onboarding.js: early serenityEnabled gate + workspace presence check;
  throw a 404/preflight-tagged error; skip cleanup for pre-flight throws;
  reuse serenityEnabled at the M6-M8 block.
- llmo.js: map a 404-typed onboarding error to notFound (matches the AIO
  Proxy convention); all other errors stay 400.
- llmo-api.yaml: document the 404 response on POST /llmo/onboard.
- tests: fail-fast unit test (404 before site creation), controller 404
  mapping test, and bind a workspace on the existing allowlist test.

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

Ai-Assisted-By: claude

Ai-Assisted-By: claude-code
@codecov
Copy link
Copy Markdown

codecov Bot commented May 29, 2026

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.

…LLMO-5204)

Implements step M7 of the Semrush onboarding orchestrator (epic LLMO-5007).
After the M5 workspace check passes, performLlmoOnboarding now fans out one
Semrush project per (market, language) tuple by calling the already-shipped
AIO Proxy handler (handleCreateProject) in-process.

Best-effort per LLMO-5007 §M7 — no retry, no rollback:
- Sequential, one upstream create+publish per tuple.
- A per-tuple failure is collected, not thrown, and does not abort the
  remaining tuples.
- A 409 (slice already exists) counts as success so re-invoking onboarding
  with the same markets[] is idempotent (proxy 409-dedupes via findBySlice).

The brand UUID (from M4 upsertBrand) keys the fan-out; the org's
semrushWorkspaceId (M5-validated) is the workspace. The IMS bearer is
forwarded to Semrush; a missing/non-IMS token or unbuildable transport is
recorded as a per-tuple failure rather than failing onboarding.

performLlmoOnboarding returns the { requested, succeeded, failed } result as
`serenity` for the M8 read-back + 200/207 response shaping (LLMO-5205, T3c).

- llmo-onboarding.js: add performSerenityFanOut + extractImsBearer; capture
  the brand from upsertBrand; call the fan-out in the M6-M8 block; thread the
  result into the return object.
- tests: 9 unit tests for performSerenityFanOut (success, 409 idempotency,
  partial failure, thrown transport error, no bearer, non-IMS auth, missing
  brand, transport build failure, empty markets).

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

Ai-Assisted-By: claude

Ai-Assisted-By: claude-code
@andreeastroe96 andreeastroe96 force-pushed the feat/LLMO-5203-serenity-flag-client branch from 20de771 to 8dcd974 Compare May 29, 2026 13:20
@andreeastroe96 andreeastroe96 changed the title feat(onboarding): T3a · fail fast on missing Semrush workspace (LLMO-5203) feat(onboarding): T3a + T3b · Semrush workspace check + project fan-out (LLMO-5203, LLMO-5204) May 29, 2026
…LLMO-5205)

Implements step M8 of the Semrush onboarding orchestrator (epic LLMO-5007).
After the M7 fan-out, read back the authoritative DB state and shape the final
200 or 207 response.

- reconcileSerenityProjects (M8): reads BrandSemrushProject.allByBrandId(brandId)
  and asserts one row exists per requested (market, language) tuple — rather than
  trusting the fan-out's own responses, which also catches the rare case where the
  proxy returned 201 but the row insert failed silently. A tuple with a matching
  row is `succeeded`; one without is `failed`, enriched with the fan-out's
  status/error when available (otherwise `projectRowMissing`). Read-back failure
  falls back to the fan-out result rather than masking a partial success.
- performLlmoOnboarding: runs M8 after M7 and returns the authoritative
  { requested, succeeded, failed } as `serenity`.
- llmo.js (controller): shape the response — all tuples present → 200 OK; some
  failed → 207 Multi-Status. Both carry the standard onboarding fields plus the
  per-tuple arrays. Successful tuples are never rolled back.
- llmo-api.yaml: clarify the 207 body mirrors the 200 plus the arrays.

- tests: 6 unit tests for reconcileSerenityProjects (empty/no-brand short-circuit,
  all-present, missing tuple enriched from fan-out error, silently-missing row →
  projectRowMissing, read-back throws → fallback) + 2 controller tests (207 on
  partial failure, 200 when all succeed).

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

Ai-Assisted-By: claude
@andreeastroe96 andreeastroe96 changed the title feat(onboarding): T3a + T3b · Semrush workspace check + project fan-out (LLMO-5203, LLMO-5204) feat(onboarding): T3 · Semrush workspace check + project fan-out + 200/207 (LLMO-5203/5204/5205) May 29, 2026
object-curly-newline (airbnb minProperties: 4) requires the failed-tuple
object literals in the no-bearer and transport-build fan-out tests to be
multi-line.

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

Ai-Assisted-By: claude
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