Skip to content

Commit 57504cb

Browse files
Iris Alexandrescuclaude
andcommitted
feat(serenity): opt-in async provisioning for createMarket, createBrandForOrg, activate (LLMO-7352/LLMO-7418)
Converts the three remaining synchronous Semrush sub-workspace/project provisioning endpoints to an opt-in async model, gated by a per-request `async: true` flag (mirroring the existing `createPrompts` precedent) so default behavior is unchanged for every current consumer. - createMarket, createBrandForOrg's initial market, and activate's project-activation branch now mint a provisioning attempt and enqueue provision-workspace-job -> {create-market,activate-markets}-job when `async: true` is supplied, returning 202 with a pollable jobId. - Business logic shared between the sync controller path and the async job handler is extracted into create-market-orchestration.js and activate-markets-orchestration.js. - guardAgainstConcurrentProvisioning protects every synchronous branch against racing a live async attempt for the same brand. - Adds a generic /serenity/jobs/:jobId polling alias. - Adds recordFreshBrandProvisioningStartFailure so a brand row created in the same request is marked failed (not left silently inert) if minting the provisioning attempt itself throws. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 70fe12d commit 57504cb

26 files changed

Lines changed: 3805 additions & 1802 deletions

docs/openapi/brands-v2-api.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ v2-brands-for-org:
7979
application/json:
8080
schema:
8181
$ref: './schemas.yaml#/V2Brand'
82+
'202':
83+
description: |
84+
PR-C (LLMO-7352/LLMO-7418): the request set `async: true` alongside
85+
`semrushMarket`. The brand row is persisted immediately (visible,
86+
non-active — "Setting up", no workspace pointer yet); the
87+
sub-workspace ready-check and the initial market's project create/
88+
publish are enqueued to a background job chain instead of running
89+
inline. Poll the returned `jobId` at `GET .../serenity/jobs/{jobId}`
90+
for status + result.
91+
content:
92+
application/json:
93+
schema:
94+
$ref: './schemas.yaml#/SerenityPromptsJobAccepted'
8295
'400':
8396
$ref: './responses.yaml#/400'
8497
'403':
@@ -104,6 +117,8 @@ v2-brands-for-org:
104117
organization (`code: brand_site_org_mismatch`).
105118
- The brand's primary URL is already the primary URL (base site) of
106119
another brand in this organization.
120+
- (PR-C, `async: true` only) a provisioning attempt is already in flight
121+
for this brand (`error: semrushProvisioningInProgress`).
107122
headers:
108123
X-Error:
109124
$ref: './headers.yaml#/xError'

docs/openapi/schemas.yaml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8068,6 +8068,50 @@ V2BrandInput:
80688068
onboarded inline. Written to `brands.site_id` as the brand's primary-URL
80698069
anchor. Sent on every create (a Serenity brand is anchored by BOTH its
80708070
sub-workspace and this primary Site).
8071+
semrushMarket:
8072+
type: object
8073+
description: >-
8074+
Provisions an initial Semrush market (project) for the brand at create
8075+
time, in a serenity-active organization. Requires `market` and
8076+
`languageCode`; omit entirely for a sub-workspace-only create (markets
8077+
added afterwards from the Markets tab).
8078+
required: [market, languageCode]
8079+
properties:
8080+
market:
8081+
type: string
8082+
pattern: '^[A-Za-z]{2}$'
8083+
description: ISO 3166-1 alpha-2 country code; resolved server-side to a Geo Target ID.
8084+
languageCode:
8085+
type: string
8086+
pattern: '^[a-z]{2,3}(-[a-z]{2,4})?$'
8087+
semrushModelIds:
8088+
type: array
8089+
items: { type: string }
8090+
description: >-
8091+
AI model (LLM) ids to attach to the initial market's project. Required
8092+
(non-empty) when `generatePrompts` is true; optional otherwise (the
8093+
project is created model-less until models are added later).
8094+
generatePrompts:
8095+
type: boolean
8096+
default: false
8097+
description: >-
8098+
Whether to generate topics/prompts for the initial `semrushMarket`
8099+
project. Requires a primary URL and `semrushModelIds`.
8100+
async:
8101+
type: boolean
8102+
default: false
8103+
description: >
8104+
PR-C (LLMO-7352/LLMO-7418). Only meaningful when `semrushMarket` is
8105+
supplied. When true, the brand row is persisted first (visible,
8106+
non-active — "Setting up"), then the sub-workspace ready-check and the
8107+
initial market's project create/publish are enqueued to a background
8108+
job chain instead of running inline, and the endpoint returns 202 with
8109+
a job id to poll via `GET .../serenity/jobs/{jobId}`. Omitted/false
8110+
processes synchronously as today (still 201) — this is opt-in only and
8111+
not yet the default. Unlike `SerenityCreatePromptsRequest`'s `async`
8112+
flag, this one is not intended to be a permanent dual-mode feature: the
8113+
synchronous path is the LLMO-7352 bug pattern itself, and is expected
8114+
to be retired once known callers have migrated to `async: true`.
80718115
80728116
V2BrandUpdateInput:
80738117
type: object
@@ -12178,6 +12222,37 @@ SerenityPromptsJobStatus:
1217812222
message: { type: string }
1217912223
- { type: 'null' }
1218012224

12225+
SerenityJobStatus:
12226+
type: object
12227+
description: |
12228+
Poll response for ANY async Serenity job — serenity-classify-prompts,
12229+
serenity-provision-workspace, serenity-create-market, or
12230+
serenity-activate-markets (PR-C, LLMO-7352/LLMO-7418). Secret-free by
12231+
design — the job's internal metadata (promise token etc.) is never exposed.
12232+
required: [jobId, status]
12233+
properties:
12234+
jobId:
12235+
type: string
12236+
format: uuid
12237+
status:
12238+
type: string
12239+
enum: [IN_PROGRESS, COMPLETED, FAILED]
12240+
result:
12241+
description: >
12242+
The job result once COMPLETED — the same body the equivalent
12243+
synchronous endpoint would have returned (shape depends on which
12244+
`async: true` call created this job: SerenityCreatePromptsResponse,
12245+
SerenityMarket, or SerenityActivateResponse). Null while IN_PROGRESS or
12246+
on FAILED.
12247+
error:
12248+
description: The failure envelope once FAILED; null otherwise.
12249+
oneOf:
12250+
- type: object
12251+
properties:
12252+
code: { type: string }
12253+
message: { type: string }
12254+
- { type: 'null' }
12255+
1218112256
SerenityUpdatePromptRequest:
1218212257
type: object
1218312258
description: |
@@ -12430,6 +12505,18 @@ SerenityActivateRequest:
1243012505
description: >-
1243112506
Optional AI model (LLM) ids to attach to this market's project
1243212507
(already-active brand reactivation).
12508+
async:
12509+
type: boolean
12510+
default: false
12511+
description: >
12512+
PR-C (LLMO-7352/LLMO-7418). Only meaningful on the already-active-brand,
12513+
markets-supplied path — a pending or bare-reactivation activation always
12514+
stays synchronous regardless of this flag. When true, the sub-workspace
12515+
ready-check and the per-market project create/publish + site-link + active
12516+
flip are enqueued to a background job chain instead of running inline, and
12517+
the endpoint returns 202 with a job id to poll via
12518+
`GET .../serenity/jobs/{jobId}`. Omitted/false processes synchronously as
12519+
today — this is opt-in only and not yet the default.
1243312520
1243412521
SerenityActivateResponse:
1243512522
type: object
@@ -12560,6 +12647,20 @@ SerenityCreateMarketRequest:
1256012647
e.g. www.nba.com/kings). Written to Semrush `settings.ai.primary_url` via
1256112648
a best-effort PATCH before publish (serenity-docs#348). Optional — falls
1256212649
back to `brandDomain` (host-only) when omitted.
12650+
async:
12651+
type: boolean
12652+
default: false
12653+
description: >
12654+
PR-C (LLMO-7352/LLMO-7418). When true (sub-workspace-mode brands only),
12655+
the sub-workspace-ready check and the project create/publish are
12656+
enqueued to a background job chain instead of running inline, and the
12657+
endpoint returns 202 with a job id to poll via
12658+
`GET .../serenity/jobs/{jobId}`. Omitted/false processes synchronously
12659+
as today — this is opt-in only and not yet the default. Unlike
12660+
`SerenityCreatePromptsRequest`'s `async` flag, this one is not intended
12661+
to be a permanent dual-mode feature: the synchronous path is the
12662+
LLMO-7352 bug pattern itself, and is expected to be retired once known
12663+
callers have migrated to `async: true`.
1256312664
1256412665
SerenityTag:
1256512666
type: object

docs/openapi/serenity-api.yaml

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,58 @@ v2-serenity-prompts-job-by-id:
327327
description: Organization, brand, or job not found, serenity is not active for the brand, or the job belongs to a different brand.
328328
'500': { $ref: './responses.yaml#/500' }
329329

330+
v2-serenity-job-by-id:
331+
parameters:
332+
- name: spaceCatId
333+
in: path
334+
required: true
335+
description: SpaceCat Organization ID (UUID)
336+
schema:
337+
type: string
338+
format: uuid
339+
- name: brandId
340+
in: path
341+
required: true
342+
description: Brand ID (UUID-only on the /serenity/* surface)
343+
schema:
344+
type: string
345+
format: uuid
346+
- name: jobId
347+
in: path
348+
required: true
349+
description: 'Async job id returned by any `async: true` 202 response.'
350+
schema:
351+
type: string
352+
format: uuid
353+
get:
354+
tags: [serenity]
355+
summary: Poll any Serenity async job (PR-C, LLMO-7352/LLMO-7418)
356+
description: |
357+
Job-type-agnostic alias of `GET .../serenity/prompts/jobs/{jobId}` (same
358+
handler — it never inspects the job's `type`, only its `brandId`). Use this
359+
path, not the prompts-named one, to poll a job returned by `async: true` on
360+
`POST .../serenity/markets`, `POST .../serenity/activate`, or
361+
`POST /v2/orgs/{spaceCatId}/brands` — the prompts path works too, but this
362+
one doesn't say "prompts" for a workspace-provisioning or market/activation
363+
job. Access is gated by the same org/brand access + serenity-active checks
364+
as every other serenity endpoint; a job that does not belong to the
365+
addressed brand 404s exactly like a missing job (jobs are never leaked
366+
across brands). The response is secret-free — only
367+
`{ jobId, status, result, error }`.
368+
operationId: getSerenityJobStatus
369+
security:
370+
- session_token: []
371+
responses:
372+
'200':
373+
description: Job status (and result when COMPLETED / error when FAILED).
374+
content:
375+
application/json:
376+
schema: { $ref: './schemas.yaml#/SerenityJobStatus' }
377+
'400': { $ref: './responses.yaml#/400' }
378+
'404':
379+
description: Organization, brand, or job not found, serenity is not active for the brand, or the job belongs to a different brand.
380+
'500': { $ref: './responses.yaml#/500' }
381+
330382
# ─── Markets ──────────────────────────────────────────────────────────────────
331383

332384
v2-serenity-markets:
@@ -393,12 +445,25 @@ v2-serenity-markets:
393445
content:
394446
application/json:
395447
schema: { $ref: './schemas.yaml#/SerenityMarket' }
448+
'202':
449+
description: |
450+
PR-C (LLMO-7352/LLMO-7418): the request set `async: true` (a
451+
sub-workspace-mode brand only). The sub-workspace-ready check and the
452+
project create/publish are enqueued to a background job chain instead
453+
of running inline. Poll the returned `jobId` at
454+
`GET .../serenity/jobs/{jobId}` for status + result — on COMPLETED,
455+
`result` is the same body a synchronous `201` would have returned.
456+
Absent/false runs synchronously as today; this is opt-in only and not
457+
yet the default.
458+
content:
459+
application/json:
460+
schema: { $ref: './schemas.yaml#/SerenityPromptsJobAccepted' }
396461
'400':
397462
description: Bad request (unknown market, malformed languageCode, missing field).
398463
'404':
399464
description: Organization has no workspace, or serenity is not active for the organization.
400465
'409':
401-
description: A market already exists for this (brandId, geoTargetId, languageCode) slice.
466+
description: A market already exists for this (brandId, geoTargetId, languageCode) slice, or (async only) a provisioning attempt is already in flight for this brand.
402467
'502':
403468
description: Upstream returned a non-2xx response.
404469
content:
@@ -915,6 +980,14 @@ v2-serenity-activate:
915980
pending and the response is HTTP 502 (its stash + workspace pointer are kept
916981
intact; retry converges idempotently). An already-active brand re-supplying
917982
markets is never downgraded (a partial failure is reported as 207).
983+
984+
PR-C (LLMO-7352/LLMO-7418): when a primary URL/domain and markets are
985+
present (the branch described above — NOT the sub-workspace-only pending
986+
or bare-reactivation paths, which always stay synchronous), the caller may
987+
set `async: true` to enqueue the whole batch to a background job chain
988+
instead of running it inline; see the `202` response. Absent/false runs
989+
synchronously as described above — this is opt-in only and not yet the
990+
default.
918991
operationId: activateSerenityBrand
919992
security:
920993
- session_token: []
@@ -929,6 +1002,19 @@ v2-serenity-activate:
9291002
content:
9301003
application/json:
9311004
schema: { $ref: './schemas.yaml#/SerenityActivateResponse' }
1005+
'202':
1006+
description: |
1007+
PR-C (LLMO-7352/LLMO-7418): the request set `async: true` on the
1008+
project-activation branch (primary URL + markets present). The whole
1009+
batch (sub-workspace ready-check, every market's project create/
1010+
publish, the site link, and the active flip) is enqueued to a
1011+
background job chain instead of running inline. Poll the returned
1012+
`jobId` at `GET .../serenity/jobs/{jobId}` for status + result — on
1013+
COMPLETED, `result` is the same body a synchronous `200`/`207` would
1014+
have returned.
1015+
content:
1016+
application/json:
1017+
schema: { $ref: './schemas.yaml#/SerenityPromptsJobAccepted' }
9321018
'207':
9331019
description: >-
9341020
An already-active brand re-supplied markets and at least one failed; the
@@ -944,9 +1030,11 @@ v2-serenity-activate:
9441030
Either (a) the primary domain is already another active brand's primary
9451031
site (`brands_base_site_unique`): the brand STAYS `pending` and the body
9461032
carries `error: serenityActivationSiteConflict` plus the per-market
947-
outcomes — the operator must choose a different primary URL; or (b)
1033+
outcomes — the operator must choose a different primary URL; (b)
9481034
misconfiguration: the brand's sub-workspace equals the organization parent
949-
workspace (refused to avoid acting on the shared parent pool).
1035+
workspace (refused to avoid acting on the shared parent pool); or (c)
1036+
(PR-C, `async: true` only) a provisioning attempt is already in flight for
1037+
this brand (`error: semrush_provisioning_in_progress`) — retry shortly.
9501038
content:
9511039
application/json:
9521040
schema:

0 commit comments

Comments
 (0)