diff --git a/src/support/serenity/brand-provisioning.js b/src/support/serenity/brand-provisioning.js index 44dd4f058..8b5c95654 100644 --- a/src/support/serenity/brand-provisioning.js +++ b/src/support/serenity/brand-provisioning.js @@ -20,6 +20,7 @@ import { isSemrushTransportError } from './errors.js'; import { resolveWorkspaceId } from './workspace-resolver.js'; import { deleteAllProjects, releaseFullAllocation, ensureSubworkspace } from './workspace-lifecycle.js'; import { handleCreateMarketSubworkspace } from './handlers/markets-subworkspace.js'; +import { isSerenityDeferPublishEnabled } from './defer-publish-active.js'; // Re-exported for callers/tests that drive brand provisioning. The tag // vocabularies themselves live in `prompt-tags.js` (single source of truth). @@ -178,6 +179,20 @@ export async function provisionBrandSubworkspace(context, { } }; + // LLMO-5492 publish-after-populate: defer-publish flag ON → leave the project a + // DRAFT ('skip') for a later finalize step (prompts + models pushed, published + // once). OFF (default) preserves inline publish: a project with models OR + // generated prompts has real units and must publish ('require'); an empty + // project would publish "empty units" (a disguised quota 405), so leave it a + // draft ('best-effort') instead of failing the create. + /** @type {'require' | 'best-effort' | 'skip'} */ + let publishMode = 'best-effort'; + if (isSerenityDeferPublishEnabled(context.env)) { + publishMode = 'skip'; + } else if ((Array.isArray(modelIds) && modelIds.length > 0) || generateTopics) { + publishMode = 'require'; + } + let result; try { result = await handleCreateMarketSubworkspace( @@ -209,14 +224,7 @@ export async function provisionBrandSubworkspace(context, { brandAliases, brandUrlSources, competitors, - // A project with neither models nor generated prompts would publish - // "empty units", which Semrush rejects with a disguised quota 405 - // (workspace doc §5). Tolerate that by leaving it a draft (best-effort) - // instead of failing the whole create; a project that has models OR - // prompts has real units and must publish (require). - publishMode: (Array.isArray(modelIds) && modelIds.length > 0) || generateTopics - ? 'require' - : 'best-effort', + publishMode, }, ); } catch (e) { diff --git a/src/support/serenity/defer-publish-active.js b/src/support/serenity/defer-publish-active.js new file mode 100644 index 000000000..b2e9bc587 --- /dev/null +++ b/src/support/serenity/defer-publish-active.js @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// @ts-check + +/** + * The GLOBAL toggle for publish-after-populate (LLMO-5492). When ON, the + * sub-workspace create path leaves each project a DRAFT (`publishMode: 'skip'`) + * instead of publishing inline, so a later finalize step (see + * `handlers/finalize.js`, driven by the DRS-completion trigger in a separate + * repo) pushes the generated prompts + models and publishes each project once. + * + * A single env boolean, DEFAULT OFF, read once per request off `context.env` — + * the same shape the other global serenity toggles use + * (`SERENITY_DYNAMIC_ALLOCATION` in dynamic-allocation-active.js, + * `SERENITY_ALLOW_NON_IMS_AUTH` in rest-transport.js). When OFF, the create path + * publishes inline byte-for-byte as before this change. Wired to Vault at + * `dx_mysticat//api-service`. + */ +export const DEFER_PUBLISH_ENV_FLAG = 'SERENITY_DEFER_PUBLISH'; + +/** + * Reads the global defer-publish toggle. `true` ONLY for the exact string + * `'true'` (env values are strings); anything else — unset, `'false'`, a typo — + * is OFF. Fail-safe by design (default: publish inline). + * @param {object} [env] - the request env (`context.env`). + * @returns {boolean} + */ +export function isSerenityDeferPublishEnabled(env) { + return env?.[DEFER_PUBLISH_ENV_FLAG] === 'true'; +} diff --git a/src/support/serenity/errors.js b/src/support/serenity/errors.js index 31b78365f..4d2c44dc7 100644 --- a/src/support/serenity/errors.js +++ b/src/support/serenity/errors.js @@ -200,6 +200,10 @@ export const ERROR_CODES = Object.freeze({ // Transient: a transfer never cleared the async `workspace not ready` lock — retryable, NOT // pool exhaustion (distinct from ORG_POOL_EXHAUSTED so the operator/client isn't misled). WORKSPACE_BUSY: 'workspaceBusy', + // Publish-after-populate (LLMO-5492): a publish rejected because the workspace + // has no `ai.projects` quota (Semrush's disguised metered 405). PERMANENT — + // alert, do not retry — distinct from the transient publish failures. + PUBLISH_QUOTA_EXHAUSTED: 'publishQuotaExhausted', // Case-1 quota rejection (serenity-docs#72 §2): the disguised-405 signal classified by // isMeteredQuota, surfaced via toQuotaExceededError. Distinct from ORG_POOL_EXHAUSTED / // BRAND_AI_LIMIT (the allocator-ON tokens) so a client need not tell them apart, but a caller diff --git a/src/support/serenity/handlers/finalize.js b/src/support/serenity/handlers/finalize.js new file mode 100644 index 000000000..cc7fb33b3 --- /dev/null +++ b/src/support/serenity/handlers/finalize.js @@ -0,0 +1,364 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// @ts-check + +import { hasText } from '@adobe/spacecat-shared-utils'; + +import { ErrorWithStatusCode } from '../../utils.js'; +import { ERROR_CODES, isMeteredQuota } from '../errors.js'; +import { handleCreatePrompts } from './prompts.js'; +import { handleUpdateModels } from './markets.js'; +import { pollProjectPublished, PUBLISH_OUTCOME } from './publish-status.js'; + +// Slice identity shared between the model results (echoed from body.models) and +// the DB rows (canonical). Normalise both sides — geoTargetId may arrive as a +// string from the trigger payload; languageCode casing is not guaranteed. +const sliceKey = (geoTargetId, languageCode) => `${Number(geoTargetId)}::${String(languageCode ?? '').toLowerCase()}`; + +/** + * LLMO-5492 — publish-after-populate finalize step. + * + * Runs when DRS/Brandalf prompt-generation completes for a brand. The brand's + * Semrush projects were provisioned as drafts by the sub-workspace create path + * (defer-publish on); this step populates and then publishes them once: + * + * 1. push generated prompts (reuses handleCreatePrompts, publish deferred), + * 2. set the selected LLMs/models per slice (reuses handleUpdateModels), + * 3. publish each of the brand's projects exactly once. + * + * It is resilient by design: a per-slice model failure or a per-project publish + * failure is recorded and the remaining work still runs, so a partial DRS + * payload still makes forward progress. + * + * Publish is gated on population — Semrush does NO content validation on publish + * (an empty project goes live), so the populate gate is entirely ours + * (serenity-docs §5). A project is published only when BOTH hold: + * - prompts: if prompts were requested for the brand, at least one was created + * (every-push-failed → skip, reason `noPrompts`); and + * - models: that project's slice ended with >=1 model set (reason `noModels` + * otherwise). Per Semrush's official setup sequence, model creation is a + * mandatory pre-publish step (serenity-docs §10 step 4 precedes step 6). + * + * DEFAULT-MODEL POLICY (trigger contract): finalize does NOT invent a fallback + * model set. `body.models` is owned by the DRS-completion trigger, which MUST + * supply >=1 model per slice it wants published. A slice that arrives with no + * models is left as an unpublished draft (recorded `publishSkipped`/`noModels` + * and logged) rather than published with an arbitrary default. So an empty + * `body.models` publishes nothing — the gate enforcing the contract. + * + * PUBLISH IS ASYNC (202 "accepted" != "observed live"). The bounded confirm only + * promotes a project to `published` when `publish_status` is read back as live. + * A 202 we could not confirm live in-budget goes to `publishPending` (NOT + * `published`) for the worker reconcile to resolve — consumers must not read a + * pending publish as live. A zero-`ai.projects`-quota rejection (405 + text/html) + * is a PERMANENT failure: `publishFailed` with `permanent: true` (alert, no retry). + * + * Retry semantics differ per step. Model sync (diff-based) and publish are + * idempotent. Prompt push is NOT: handleCreatePrompts unconditionally creates + * each input upstream, so a re-fire would duplicate prompts in Semrush. The + * DRS-completion *trigger* (an audit-worker/SQS job, a separate repo) must + * therefore deliver the prompt payload exactly once — it is intentionally NOT + * wired here; this is the reusable mechanism it will call. + * + * @param {object} transport - Serenity REST transport. + * @param {object} dataAccess - SpaceCat data-access layer. + * @param {string} brandId - Brand whose projects to finalize. + * @param {string} semrushWorkspaceId - Org's Semrush workspace id. + * @param {object} body - { prompts?: Array, models?: Array<{geoTargetId, languageCode, modelIds}> } + * @param {object} [log] - Logger. + * @param {function} [classifyPromptType] - Branded/non-branded classifier threaded + * into handleCreatePrompts (the caller builds it from the brand's aliases, same + * as the create/prompt endpoints). + * @param {object} [options] - Publish-confirm tuning (AC3). + * @param {number} [options.confirmAttempts=1] - Max getProjectStatus reads per + * project after publish (bounded to the Lambda budget; the unbounded ≤900s + * reconcile loop is the DRS/worker's job, not this Lambda's). + * @param {number} [options.confirmIntervalMs=0] - Delay between confirm reads. + * @param {number} [options.deadlineMs] - Epoch-ms wall-time deadline (e.g. from + * the Lambda's getRemainingTimeInMillis). When the remaining budget drops below + * a safety margin, remaining projects are deferred to the worker reconcile + * (publishPending, reason 'deadline') instead of risking a mid-publish timeout. + * Omitted → unbounded (publish every project). + * @returns {Promise<{prompts: object, models: Array, published: string[], + * publishPending: Array, publishSkipped: Array, publishFailed: Array}>} + */ +export async function finalizeSerenityProjects( + transport, + dataAccess, + brandId, + semrushWorkspaceId, + body, + log, + classifyPromptType, + options = {}, +) { + if (!hasText(brandId)) { + throw new ErrorWithStatusCode('brandId is required', 400); + } + if (!hasText(semrushWorkspaceId)) { + throw new ErrorWithStatusCode('semrushWorkspaceId is required', 400); + } + + // 1. Push prompts. publish:false so the single authoritative publish happens + // in step 3 after models are also set — never an empty/half-populated + // publish. + /** @type {{created: Array, skipped: Array, failed: Array}} */ + let prompts = { created: [], skipped: [], failed: [] }; + const promptInputs = Array.isArray(body?.prompts) ? body.prompts : []; + if (promptInputs.length > 0) { + prompts = await handleCreatePrompts( + transport, + dataAccess, + brandId, + semrushWorkspaceId, + { prompts: promptInputs }, + log, + classifyPromptType, + { publish: false }, + ); + } + + // 2. Set models per slice. A per-slice failure is recorded, not fatal — the + // remaining slices and the publish step still run. + const models = []; + const modelSlices = Array.isArray(body?.models) ? body.models : []; + for (const slice of modelSlices) { + try { + // eslint-disable-next-line no-await-in-loop + const result = await handleUpdateModels( + transport, + dataAccess, + brandId, + semrushWorkspaceId, + slice, + log, + // Publish stays deferred to step 3 — handleUpdateModels must not push + // a per-slice publish of its own here. + { publish: false }, + ); + models.push({ + geoTargetId: slice?.geoTargetId, + languageCode: slice?.languageCode, + status: 200, + items: result.items, + }); + } catch (e) { + log?.error?.('finalizeSerenityProjects: set models failed for slice', { + brandId, + geoTargetId: slice?.geoTargetId, + languageCode: slice?.languageCode, + error: e.message, + }); + models.push({ + geoTargetId: slice?.geoTargetId, + languageCode: slice?.languageCode, + status: e.status || 500, + error: e.message, + }); + } + } + + // Resolve the brand's project rows once — needed by both populate gates and + // the publish loop. Dedupe projectId → its slice(s) so a project shared by + // multiple slices (defensive — slices map 1:1 to projects today) is handled + // exactly once. + const rows = (await dataAccess.BrandSemrushProject.allByBrandId(brandId)) || []; + const projectSlices = new Map(); + for (const r of rows) { + const id = r.getSemrushProjectId(); + if (!hasText(id)) { + // eslint-disable-next-line no-continue + continue; + } + if (!projectSlices.has(id)) { + projectSlices.set(id, []); + } + projectSlices.get(id).push({ + geoTargetId: r.getGeoTargetId(), + languageCode: r.getLanguageCode(), + }); + } + + const published = []; + const publishPending = []; + const publishSkipped = []; + const publishFailed = []; + + const result = () => { + log?.info?.('finalizeSerenityProjects: complete', { + brandId, + promptsCreated: prompts.created.length, + promptsFailed: prompts.failed.length, + promptsSkipped: prompts.skipped.length, + modelSlices: models.length, + modelSlicesFailed: models.filter((m) => m.status !== 200).length, + published: published.length, + publishPending: publishPending.length, + publishSkipped: publishSkipped.length, + publishFailed: publishFailed.length, + }); + return { + prompts, models, published, publishPending, publishSkipped, publishFailed, + }; + }; + + // POPULATE GATE (prompts). Semrush does no content validation on publish, so + // guarding against an empty publish is entirely ours. If prompts were + // requested for the brand but every push failed, the projects are still empty + // drafts — skip publish for ALL of them (the trigger retries the prompt push). + const promptsRequested = promptInputs.length > 0; + if (promptsRequested && prompts.created.length === 0) { + log?.warn?.( + 'finalizeSerenityProjects: every prompt push failed — skipping publish to avoid ' + + 'publishing empty projects', + { + brandId, + promptsFailed: prompts.failed.length, + promptsSkipped: prompts.skipped.length, + }, + ); + for (const projectId of projectSlices.keys()) { + publishSkipped.push({ projectId, reason: 'noPrompts' }); + } + return result(); + } + + // POPULATE GATE (models) + trigger contract. Model creation is a mandatory + // pre-publish step (serenity-docs §10). Build the set of slices that ended + // with >=1 model; a project is publishable only if at least one of its slices + // is in that set. A project with no models is left as a draft (publishSkipped / + // noModels) — finalize never invents a default model set; the DRS trigger owns + // supplying models. Empty body.models therefore publishes nothing. + const slicesWithModels = new Set( + models + .filter((m) => m.status === 200 && Array.isArray(m.items) && m.items.length > 0) + .map((m) => sliceKey(m.geoTargetId, m.languageCode)), + ); + + const publishable = []; + for (const [projectId, slices] of projectSlices) { + const hasModels = slices.some( + (s) => slicesWithModels.has(sliceKey(s.geoTargetId, s.languageCode)), + ); + if (hasModels) { + publishable.push(projectId); + } else { + log?.warn?.( + 'finalizeSerenityProjects: skipping publish — no models set for project ' + + '(trigger contract: the DRS payload must supply >=1 model per slice to publish)', + { brandId, projectId }, + ); + publishSkipped.push({ projectId, reason: 'noModels' }); + } + } + + // 3. Publish once per publishable project. Publish is async (202) with no + // completion webhook; after it is accepted we do a BOUNDED confirm via the + // project's `publish_status` (the unbounded ≤900s reconcile is the worker's + // job). Bucketing — note `published` means CONFIRMED LIVE only: + // - confirmed live → published + // - initial_publish_failed → publishFailed (terminal) + // - 405 + text/html (no quota) → publishFailed { permanent } (alert, no retry) + // - other publish error → publishFailed (transient; retry/reconcile) + // - accepted but not confirmed live in-budget, OR status unreadable, OR + // no getProjectStatus → publishPending (worker reconciles) — + // we never report an unconfirmed 202 as live. + const { + confirmAttempts = 1, confirmIntervalMs = 0, deadlineMs, + } = options; + const canConfirm = typeof transport.getProjectStatus === 'function'; + // Wall-time guard: publish is sequential (publish + bounded confirm per + // project), so a large brand could exceed the Lambda budget. When the caller + // supplies `deadlineMs` (epoch ms, e.g. from getRemainingTimeInMillis), stop + // publishing once the remaining budget drops below WALL_SAFETY_MS and hand the + // rest to the worker reconcile as `publishPending` (reason: 'deadline') rather + // than risk an abrupt timeout mid-publish. No deadline → unbounded (as today). + const WALL_SAFETY_MS = 5000; + for (let i = 0; i < publishable.length; i += 1) { + const projectId = publishable[i]; + if (deadlineMs && Date.now() + WALL_SAFETY_MS >= deadlineMs) { + for (const remaining of publishable.slice(i)) { + publishPending.push({ projectId: remaining, status: null, reason: 'deadline' }); + } + log?.warn?.( + 'finalizeSerenityProjects: wall-time budget nearly exhausted — deferring ' + + 'remaining publishes to the worker reconcile', + { brandId, deferred: publishable.length - i }, + ); + break; + } + try { + // eslint-disable-next-line no-await-in-loop + await transport.publishProject(semrushWorkspaceId, projectId); + } catch (e) { + if (isMeteredQuota(e)) { + log?.error?.( + 'finalizeSerenityProjects: publish rejected — workspace has no ai.projects quota ' + + '(PERMANENT, alert; not retried)', + { brandId, projectId, code: ERROR_CODES.PUBLISH_QUOTA_EXHAUSTED }, + ); + publishFailed.push({ + projectId, + error: 'publish rejected: workspace has no ai.projects quota', + code: ERROR_CODES.PUBLISH_QUOTA_EXHAUSTED, + permanent: true, + }); + } else { + log?.error?.('finalizeSerenityProjects: publish failed', { + brandId, + projectId, + error: e.message, + }); + publishFailed.push({ projectId, error: e.message }); + } + // eslint-disable-next-line no-continue + continue; + } + + if (!canConfirm) { + // Accepted (202) but we have no way to confirm it went live → pending. + publishPending.push({ projectId, status: null }); + // eslint-disable-next-line no-continue + continue; + } + + // eslint-disable-next-line no-await-in-loop + const confirm = await pollProjectPublished(transport, semrushWorkspaceId, projectId, { + attempts: confirmAttempts, + intervalMs: confirmIntervalMs, + log, + }); + if (confirm.outcome === PUBLISH_OUTCOME.PUBLISHED) { + published.push(projectId); + } else if (confirm.outcome === PUBLISH_OUTCOME.FAILED) { + log?.error?.('finalizeSerenityProjects: publish reported failed by upstream', { + brandId, + projectId, + publishStatus: confirm.status, + failedReason: confirm.failedReason, + }); + publishFailed.push({ + projectId, + error: confirm.failedReason || confirm.status || 'initial_publish_failed', + publishStatus: confirm.status, + }); + } else { + // PENDING — accepted, but not confirmed live within budget (still + // draft/publishing, or status unreadable). The worker reconcile resolves + // it; until then it is explicitly NOT reported as live. + publishPending.push({ projectId, status: confirm.status }); + } + } + + return result(); +} diff --git a/src/support/serenity/handlers/markets.js b/src/support/serenity/handlers/markets.js index 9e4b52e69..f6462d386 100644 --- a/src/support/serenity/handlers/markets.js +++ b/src/support/serenity/handlers/markets.js @@ -1200,6 +1200,12 @@ export async function syncModelsForProject( * internally for the DELETE batch and are never exposed to callers. * * Returns the final model list in the same shape as `handleListModels`. + * + * `publish` (default true — the standalone-endpoint contract) commits the + * model-set change to the live project. Set it false when the caller batches + * its own publish afterwards (LLMO-5492 publish-after-populate: finalize sets + * models with publish deferred, then publishes each project once) — the inner + * publish is forwarded to {@link syncModelsForProject}. */ export async function handleUpdateModels( transport, @@ -1208,6 +1214,7 @@ export async function handleUpdateModels( semrushWorkspaceId, body, log, + { publish = true } = {}, ) { const geoTargetId = normalizeGeoTargetId(Number(body?.geoTargetId)); const languageCode = normalizeLanguageCode(body?.languageCode); @@ -1246,5 +1253,6 @@ export async function handleUpdateModels( modelIds, { brandId, geoTargetId, languageCode }, log, + { publish }, ); } diff --git a/src/support/serenity/handlers/prompts.js b/src/support/serenity/handlers/prompts.js index e70b46752..496273476 100644 --- a/src/support/serenity/handlers/prompts.js +++ b/src/support/serenity/handlers/prompts.js @@ -555,6 +555,12 @@ export async function mapLimit(items, limit, mapper) { * Each input must carry `(geoTargetId, languageCode, text, tags?)`. Inputs * are grouped by slice; the matching BrandSemrushProject row resolves the * upstream project; publish runs once per affected project at the end. + * + * `publish` (default true — the standalone-endpoint contract) commits the new + * prompts to the live project. Set it false when the caller batches its own + * publish afterwards (LLMO-5492 publish-after-populate: finalize pushes prompts + * + models and publishes each project once) — an intermediate publish would + * either go live half-populated or, on a model-less draft, throw. */ export async function handleCreatePrompts( transport, @@ -564,6 +570,7 @@ export async function handleCreatePrompts( body, log, classifyPromptType, + { publish = true } = {}, ) { const inputs = Array.isArray(body?.prompts) ? body.prompts : []; if (inputs.length === 0) { @@ -670,20 +677,24 @@ export async function handleCreatePrompts( invalidateTagCacheForProject(semrushWorkspaceId, pid); } - const publishErrors = await publishAffected( - transport, - semrushWorkspaceId, - affectedProjectIds, - log, - ); - // publishAffected returns already-redacted { projectId, message } records; - // pubErr is a record, not a raw error, so pubErr.message is safe to surface. - for (const pubErr of publishErrors) { - failed.push({ - text: '', - status: 502, - message: `publish: ${pubErr.message}`, - }); + // publish:false — the caller (finalize) batches a single publish after models + // are also set, so skip the per-create publish here. + if (publish) { + const publishErrors = await publishAffected( + transport, + semrushWorkspaceId, + affectedProjectIds, + log, + ); + // publishAffected returns already-redacted { projectId, message } records; + // pubErr is a record, not a raw error, so pubErr.message is safe to surface. + for (const pubErr of publishErrors) { + failed.push({ + text: '', + status: 502, + message: `publish: ${pubErr.message}`, + }); + } } return { created, skipped, failed }; diff --git a/src/support/serenity/handlers/publish-status.js b/src/support/serenity/handlers/publish-status.js new file mode 100644 index 000000000..4f7218ca8 --- /dev/null +++ b/src/support/serenity/handlers/publish-status.js @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// @ts-check + +/** + * LLMO-5492 / AC3 — publish-completion status reading. + * + * Semrush publishes a project asynchronously: POST .../publish returns 202 and + * the project's `publish_status` attribute transitions in the background, with + * no completion webhook. Completion is therefore observed by re-reading the + * project (transport.getProjectStatus) and inspecting `publish_status`. + * + * This module is the reusable read/classify/poll mechanism. Two consumers: + * - finalize.js does a BOUNDED, best-effort confirm within the Lambda's wall + * budget (one or a few reads) to catch an early hard failure. + * - the DRS/audit worker (a separate repo, SQS-driven, ≤900s) drives the + * UNBOUNDED long-poll using the same classify logic. + * + * Enum (serenity-docs §6): + * draft | publishing | initial_publish_failed | live | live_with_unpublished_updates + */ + +export const PUBLISH_STATUS = { + DRAFT: 'draft', + PUBLISHING: 'publishing', + INITIAL_PUBLISH_FAILED: 'initial_publish_failed', + LIVE: 'live', + LIVE_WITH_UNPUBLISHED_UPDATES: 'live_with_unpublished_updates', +}; + +// "Published" = the project is live to consumers, whether or not it carries +// later unpublished draft edits. +const PUBLISHED_STATES = new Set([ + PUBLISH_STATUS.LIVE, + PUBLISH_STATUS.LIVE_WITH_UNPUBLISHED_UPDATES, +]); + +// Terminal failure of the FIRST publish. `publishing` is in-progress (not a +// failure); only `initial_publish_failed` is terminal-bad. +const FAILED_STATES = new Set([PUBLISH_STATUS.INITIAL_PUBLISH_FAILED]); + +/** + * Outcome buckets returned by classifyPublishStatus / pollProjectPublished. + * `pending` covers draft, publishing, and any unknown/absent status — i.e. + * "not yet confirmed live, not yet confirmed failed". + */ +export const PUBLISH_OUTCOME = { + PUBLISHED: 'published', + FAILED: 'failed', + PENDING: 'pending', +}; + +const DEFAULT_ATTEMPTS = 1; +const DEFAULT_INTERVAL_MS = 0; + +const defaultSleep = (ms) => new Promise((resolve) => { + setTimeout(resolve, ms); +}); + +/** + * Reads `publish_status` off a project payload, tolerating both the upstream + * snake_case form and a normalised camelCase form. + * @param {object} project - Raw project JSON from getProjectStatus. + * @returns {string|null} + */ +export function readPublishStatus(project) { + return project?.publish_status ?? project?.publishStatus ?? null; +} + +/** + * Maps a project payload to a PUBLISH_OUTCOME (`published`|`failed`|`pending`). + * @param {object} project - Raw project JSON from getProjectStatus. + * @returns {string} + */ +export function classifyPublishStatus(project) { + const status = readPublishStatus(project); + if (status && PUBLISHED_STATES.has(status)) { + return PUBLISH_OUTCOME.PUBLISHED; + } + if (status && FAILED_STATES.has(status)) { + return PUBLISH_OUTCOME.FAILED; + } + return PUBLISH_OUTCOME.PENDING; +} + +/** + * Polls getProjectStatus until the project is confirmed live or confirmed + * failed, or the attempt budget is exhausted. A status-read error is non-fatal + * to the poll (logged, retried, and reported as `pending` if it persists) — + * the caller decides how to treat an unconfirmed publish. + * + * Bounded by `attempts`/`intervalMs`. finalize uses a tiny budget (the long + * ≤900s reconcile loop is the worker's job); the worker passes a large one. + * + * @param {object} transport - Serenity REST transport (needs getProjectStatus). + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {object} [opts] + * @param {number} [opts.attempts=1] - Max status reads (>=1). + * @param {number} [opts.intervalMs=0] - Delay between reads. + * @param {object} [opts.log] - Logger. + * @param {function} [opts.sleep] - Injectable delay (tests pass a no-op). + * @returns {Promise<{outcome:string, status:(string|null), publishedAt:(string|null), + * failedReason:(string|null), attempts:number, error?:string}>} + */ +export async function pollProjectPublished( + transport, + semrushWorkspaceId, + projectId, + { + attempts = DEFAULT_ATTEMPTS, + intervalMs = DEFAULT_INTERVAL_MS, + log, + sleep = defaultSleep, + } = {}, +) { + const total = Math.max(1, attempts); + /** @type {{outcome:string, status:(string|null), publishedAt:(string|null), + * failedReason:(string|null), attempts:number, error?:string}} */ + let last = { + outcome: PUBLISH_OUTCOME.PENDING, + status: null, + publishedAt: null, + failedReason: null, + attempts: 0, + }; + + for (let i = 0; i < total; i += 1) { + let project; + let readError; + try { + // eslint-disable-next-line no-await-in-loop + project = await transport.getProjectStatus(semrushWorkspaceId, projectId); + } catch (e) { + readError = e; + log?.warn?.('pollProjectPublished: status read failed', { + projectId, attempt: i + 1, error: e.message, + }); + } + + if (!readError) { + const status = readPublishStatus(project); + last = { + outcome: classifyPublishStatus(project), + status, + publishedAt: project?.published_at ?? project?.publishedAt ?? null, + failedReason: project?.publishing_failed_reason ?? project?.publishingFailedReason ?? null, + attempts: i + 1, + }; + if (last.outcome !== PUBLISH_OUTCOME.PENDING) { + return last; + } + } else { + last = { ...last, attempts: i + 1, error: readError.message }; + } + + if (i < total - 1 && intervalMs > 0) { + // eslint-disable-next-line no-await-in-loop + await sleep(intervalMs); + } + } + + return last; +} diff --git a/src/support/serenity/rest-transport.js b/src/support/serenity/rest-transport.js index 6016b1af6..c586a07c8 100644 --- a/src/support/serenity/rest-transport.js +++ b/src/support/serenity/rest-transport.js @@ -460,6 +460,34 @@ export function createSerenityTransport({ env, imsToken }) { ); }, + /** + * GET /v1/workspaces/{ws}/projects/{pid} — reads a single project for its + * `publish_status` (LLMO-5492 / AC3). Semrush publishes asynchronously with + * no completion webhook, so publish completion is observed by re-reading the + * project and inspecting `publish_status` + * (draft | publishing | initial_publish_failed | live | + * live_with_unpublished_updates — serenity-docs §6). The default view echoes + * `publish_status` faithfully (§10). Consumed by + * {@link module:handlers/publish-status.pollProjectPublished}. + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @returns {Promise} raw project JSON carrying `publish_status`. + */ + async getProjectStatus(semrushWorkspaceId, projectId) { + // draft:'true' reads the draft view, which echoes `publish_status` for a + // never-published project; the live view (draft:'false') empties a + // never-published draft's config (serenity-docs #12 §10) so its status + // can't be read back. Matches getProject's default (draft:true). + return projects.getProject( + { + params: { + path: { id: semrushWorkspaceId, project_id: projectId }, + query: { draft: 'true', type: 'ai' }, + }, + }, + ); + }, + /** * GET /v1/workspaces/{ws}/projects/{pid}/ai_models — list AI models * configured for a project. `model.key` is the value the Reporting API diff --git a/test/support/serenity/brand-provisioning.test.js b/test/support/serenity/brand-provisioning.test.js index 710dabb93..40fdfd0c4 100644 --- a/test/support/serenity/brand-provisioning.test.js +++ b/test/support/serenity/brand-provisioning.test.js @@ -254,6 +254,22 @@ describe('provisionBrandSubworkspace', () => { expect(options.publishMode).to.equal('best-effort'); }); + it('leaves the initial market a DRAFT (publishMode "skip") when SERENITY_DEFER_PUBLISH is on (LLMO-5492)', async () => { + const { provisionBrandSubworkspace } = await loadModule({ + resolveWorkspaceId, handleCreateMarketSubworkspace, + }); + const context = buildContext(); + context.env.SERENITY_DEFER_PUBLISH = 'true'; + // modelIds + generateTopics would normally force publishMode 'require'; the + // defer-publish flag overrides it so the create path leaves a draft for finalize. + await provisionBrandSubworkspace(context, { + ...baseParams, modelIds: ['m1'], generateTopics: true, + }); + const { args } = handleCreateMarketSubworkspace.firstCall; + const [, , , , , , , options] = args; + expect(options.publishMode).to.equal('skip'); + }); + it('forwards brandAliases to the handler for branded prompt classification', async () => { const { provisionBrandSubworkspace } = await loadModule({ resolveWorkspaceId, handleCreateMarketSubworkspace, diff --git a/test/support/serenity/defer-publish-active.test.js b/test/support/serenity/defer-publish-active.test.js new file mode 100644 index 000000000..6e3a49827 --- /dev/null +++ b/test/support/serenity/defer-publish-active.test.js @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; + +import { + DEFER_PUBLISH_ENV_FLAG, + isSerenityDeferPublishEnabled, +} from '../../../src/support/serenity/defer-publish-active.js'; + +describe('serenity defer-publish flag (LLMO-5492)', () => { + it('exposes the env flag name', () => { + expect(DEFER_PUBLISH_ENV_FLAG).to.equal('SERENITY_DEFER_PUBLISH'); + }); + + it('is ON only for the exact string "true"', () => { + expect(isSerenityDeferPublishEnabled({ SERENITY_DEFER_PUBLISH: 'true' })).to.be.true; + }); + + it('is OFF (fail-safe) for false, typos, non-string, unset, and missing env', () => { + expect(isSerenityDeferPublishEnabled({ SERENITY_DEFER_PUBLISH: 'false' })).to.be.false; + expect(isSerenityDeferPublishEnabled({ SERENITY_DEFER_PUBLISH: 'TRUE' })).to.be.false; + expect(isSerenityDeferPublishEnabled({ SERENITY_DEFER_PUBLISH: true })).to.be.false; + expect(isSerenityDeferPublishEnabled({})).to.be.false; + expect(isSerenityDeferPublishEnabled(undefined)).to.be.false; + }); +}); diff --git a/test/support/serenity/errors.test.js b/test/support/serenity/errors.test.js index dd48b99af..1b39197c6 100644 --- a/test/support/serenity/errors.test.js +++ b/test/support/serenity/errors.test.js @@ -104,6 +104,7 @@ describe('serenity error classification', () => { expect(ERROR_CODES.LINKED_SUBWORKSPACES).to.equal('linkedSubworkspaces'); expect(ERROR_CODES.ORG_POOL_EXHAUSTED).to.equal('orgPoolExhausted'); expect(ERROR_CODES.BRAND_AI_LIMIT).to.equal('brandAiLimit'); + expect(ERROR_CODES.PUBLISH_QUOTA_EXHAUSTED).to.equal('publishQuotaExhausted'); expect(ERROR_CODES.QUOTA_EXCEEDED).to.equal('quotaExceeded'); expect(Object.isFrozen(ERROR_CODES)).to.be.true; }); diff --git a/test/support/serenity/handlers/finalize.test.js b/test/support/serenity/handlers/finalize.test.js new file mode 100644 index 000000000..289a39058 --- /dev/null +++ b/test/support/serenity/handlers/finalize.test.js @@ -0,0 +1,266 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { use, expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +import { ErrorWithStatusCode } from '../../../../src/support/utils.js'; +import { SerenityTransportError } from '../../../../src/support/serenity/rest-transport.js'; +import { ERROR_CODES } from '../../../../src/support/serenity/errors.js'; + +use(chaiAsPromised); +use(sinonChai); + +const PROMPTS_PATH = '../../../../src/support/serenity/handlers/prompts.js'; +const MARKETS_PATH = '../../../../src/support/serenity/handlers/markets.js'; +const FINALIZE_PATH = '../../../../src/support/serenity/handlers/finalize.js'; + +// A BrandSemrushProject-shaped row. +const row = (semrushProjectId, geoTargetId, languageCode) => ({ + getSemrushProjectId: () => semrushProjectId, + getGeoTargetId: () => geoTargetId, + getLanguageCode: () => languageCode, +}); + +const noopLog = { + info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, +}; + +describe('finalizeSerenityProjects (publish-after-populate)', () => { + let sandbox; + let handleCreatePrompts; + let handleUpdateModels; + let finalizeSerenityProjects; + let transport; + let dataAccess; + + const WS = 'ws-1'; + const BRAND = 'brand-uuid-1'; + + const load = async () => { + ({ finalizeSerenityProjects } = await esmock(FINALIZE_PATH, { + [PROMPTS_PATH]: { handleCreatePrompts }, + [MARKETS_PATH]: { handleUpdateModels }, + })); + }; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + handleCreatePrompts = sandbox.stub().resolves({ created: [{ semrushPromptId: 'p1' }], skipped: [], failed: [] }); + handleUpdateModels = sandbox.stub().resolves({ items: [{ id: 'm1' }] }); + transport = { + publishProject: sandbox.stub().resolves(), + getProjectStatus: sandbox.stub().resolves({ publish_status: 'live' }), + }; + dataAccess = { + BrandSemrushProject: { + allByBrandId: sandbox.stub().resolves([row('proj-1', 2840, 'en')]), + }, + }; + await load(); + }); + + afterEach(() => sandbox.restore()); + + it('throws 400 when brandId is missing', async () => { + await expect( + finalizeSerenityProjects(transport, dataAccess, '', WS, {}, noopLog), + ).to.be.rejectedWith(ErrorWithStatusCode, 'brandId is required'); + }); + + it('throws 400 when semrushWorkspaceId is missing', async () => { + await expect( + finalizeSerenityProjects(transport, dataAccess, BRAND, '', {}, noopLog), + ).to.be.rejectedWith(ErrorWithStatusCode, 'semrushWorkspaceId is required'); + }); + + it('populates then publishes and confirms live → published', async () => { + const classify = sandbox.stub(); + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [{ geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }], + }; + const out = await finalizeSerenityProjects( + transport, + dataAccess, + BRAND, + WS, + body, + noopLog, + classify, + ); + + // prompts pushed with publish deferred + classifier threaded through + expect(handleCreatePrompts).to.have.been.calledOnce; + const pArgs = handleCreatePrompts.firstCall.args; + expect(pArgs[6]).to.equal(classify); + expect(pArgs[7]).to.deep.equal({ publish: false }); + // models set with publish deferred + expect(handleUpdateModels.firstCall.args[6]).to.deep.equal({ publish: false }); + // single publish + confirm + expect(transport.publishProject).to.have.been.calledOnceWith(WS, 'proj-1'); + expect(out.published).to.deep.equal(['proj-1']); + expect(out.publishPending).to.be.empty; + expect(out.publishFailed).to.be.empty; + expect(out.publishSkipped).to.be.empty; + }); + + it('skips publish for ALL projects when prompts were requested but every push failed (noPrompts)', async () => { + handleCreatePrompts.resolves({ created: [], skipped: [], failed: [{ text: 'q' }] }); + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [{ geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }], + }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + expect(transport.publishProject).to.not.have.been.called; + expect(out.publishSkipped).to.deep.equal([{ projectId: 'proj-1', reason: 'noPrompts' }]); + expect(out.published).to.be.empty; + }); + + it('skips publish for a project with no models set (noModels)', async () => { + // prompts ok, but no models supplied at all + const body = { prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }] }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + expect(transport.publishProject).to.not.have.been.called; + expect(out.publishSkipped).to.deep.equal([{ projectId: 'proj-1', reason: 'noModels' }]); + }); + + it('records a per-slice model failure without aborting the run', async () => { + handleUpdateModels.rejects(new ErrorWithStatusCode('boom', 500)); + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [{ geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }], + }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + expect(out.models[0].status).to.equal(500); + // slice had no successful models → project not publishable + expect(out.publishSkipped).to.deep.equal([{ projectId: 'proj-1', reason: 'noModels' }]); + expect(transport.publishProject).to.not.have.been.called; + }); + + it('classifies a metered-quota publish rejection (405 + text body) as permanent publishFailed', async () => { + transport.publishProject.rejects(new SerenityTransportError(405, 'nope', 'method not allowed')); + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [{ geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }], + }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + expect(out.published).to.be.empty; + expect(out.publishFailed).to.have.lengthOf(1); + expect(out.publishFailed[0]).to.include({ + projectId: 'proj-1', + code: ERROR_CODES.PUBLISH_QUOTA_EXHAUSTED, + permanent: true, + }); + }); + + it('records a generic publish error as (transient) publishFailed', async () => { + transport.publishProject.rejects(new Error('network flake')); + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [{ geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }], + }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + expect(out.publishFailed).to.have.lengthOf(1); + expect(out.publishFailed[0].error).to.equal('network flake'); + expect(out.publishFailed[0].permanent).to.be.undefined; + }); + + it('reports publishPending when the publish is accepted but not confirmed live in budget', async () => { + transport.getProjectStatus.resolves({ publish_status: 'publishing' }); + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [{ geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }], + }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + expect(out.published).to.be.empty; + expect(out.publishPending).to.deep.equal([{ projectId: 'proj-1', status: 'publishing' }]); + }); + + it('reports publishFailed when upstream confirms initial_publish_failed', async () => { + transport.getProjectStatus.resolves({ publish_status: 'initial_publish_failed', publishing_failed_reason: 'nginx 500' }); + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [{ geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }], + }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + expect(out.publishFailed).to.have.lengthOf(1); + expect(out.publishFailed[0]).to.include({ projectId: 'proj-1', error: 'nginx 500' }); + }); + + it('reports publishPending when the transport cannot confirm (no getProjectStatus)', async () => { + delete transport.getProjectStatus; + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [{ geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }], + }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + expect(out.publishPending).to.deep.equal([{ projectId: 'proj-1', status: null }]); + }); + + it('skips the prompt push when none supplied (models-only publishes)', async () => { + const body = { models: [{ geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }] }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + expect(handleCreatePrompts).to.not.have.been.called; + expect(out.published).to.deep.equal(['proj-1']); + }); + + it('skips a brand project row with a blank semrushProjectId', async () => { + dataAccess.BrandSemrushProject.allByBrandId.resolves([ + row('', 2724, 'es'), // blank id — must be skipped, never published + row('proj-1', 2840, 'en'), + ]); + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [ + { geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }, + { geoTargetId: 2724, languageCode: 'es', modelIds: ['m1'] }, + ], + }; + const out = await finalizeSerenityProjects(transport, dataAccess, BRAND, WS, body, noopLog); + // Only the real project is acted on; the blank-id row is dropped entirely. + expect(out.published).to.deep.equal(['proj-1']); + expect(transport.publishProject).to.have.been.calledOnceWith(WS, 'proj-1'); + }); + + it('defers remaining publishes to the reconcile when the wall-time budget is exhausted', async () => { + dataAccess.BrandSemrushProject.allByBrandId.resolves([ + row('proj-1', 2840, 'en'), + row('proj-2', 2724, 'es'), + ]); + const body = { + prompts: [{ text: 'q', geoTargetId: 2840, languageCode: 'en' }], + models: [ + { geoTargetId: 2840, languageCode: 'en', modelIds: ['m1'] }, + { geoTargetId: 2724, languageCode: 'es', modelIds: ['m1'] }, + ], + }; + // deadline already passed → the guard fires before the first publish. + const out = await finalizeSerenityProjects( + transport, + dataAccess, + BRAND, + WS, + body, + noopLog, + undefined, + { deadlineMs: Date.now() - 1 }, + ); + expect(transport.publishProject).to.not.have.been.called; + expect(out.published).to.be.empty; + expect(out.publishPending).to.have.lengthOf(2); + expect(out.publishPending.every((p) => p.reason === 'deadline')).to.be.true; + }); +}); diff --git a/test/support/serenity/handlers/markets.test.js b/test/support/serenity/handlers/markets.test.js index db89e3081..c0d936647 100644 --- a/test/support/serenity/handlers/markets.test.js +++ b/test/support/serenity/handlers/markets.test.js @@ -1442,6 +1442,37 @@ describe('handlers/markets.js — handleUpdateModels', () => { expect(result.items[0].id).to.equal('cat-gpt'); }); + // LLMO-5492 — deferred publish: with { publish: false } the model-set diff is + // still applied upstream, but publishProject is NOT called, so finalize can + // batch a single populate-then-publish across prompts + models. + it('applies the model diff but does NOT publish when { publish: false }', async () => { + const project = makeProject({ semrushProjectId: 'proj-1', geoTargetId: 2840, languageCode: 'en' }); + const da = makeDataAccess([]); + da.BrandSemrushProject.findBySlice.resolves(project); + const transport = makeTransport({ currentItems: [] }); + transport.listAiModels.onSecondCall().resolves({ + items: [{ + id: 'assign-1', + model: { + id: 'cat-gpt', key: 'chatgpt', name: 'ChatGPT', icon: null, + }, + }], + }); + + await handleUpdateModels( + transport, + da, + BRAND, + WORKSPACE, + { geoTargetId: 2840, languageCode: 'en', modelIds: ['cat-gpt'] }, + fakeLog(), + { publish: false }, + ); + + expect(transport.addAiModel).to.have.been.calledOnceWith(WORKSPACE, 'proj-1', 'cat-gpt'); + expect(transport.publishProject).to.not.have.been.called; + }); + it('removes models absent from the desired set', async () => { const project = makeProject({ semrushProjectId: 'proj-1', geoTargetId: 2840, languageCode: 'en' }); const da = makeDataAccess([]); diff --git a/test/support/serenity/handlers/prompts.test.js b/test/support/serenity/handlers/prompts.test.js index 3803c89ad..9f6293ab7 100644 --- a/test/support/serenity/handlers/prompts.test.js +++ b/test/support/serenity/handlers/prompts.test.js @@ -582,6 +582,34 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { expect(transport.publishProject).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en'); }); + // LLMO-5492 — deferred publish: with { publish: false } the prompt is still + // created upstream but the project is NOT published, so the finalize step can + // batch a single populate-then-publish. classifyPromptType is passed as the + // 7th positional arg (undefined here) and the option object as the 8th. + it('creates the prompt but does NOT publish when { publish: false }', async () => { + const project = makeProject({ + semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en', + }); + const dataAccess = makeDataAccess([project]); + const transport = { + listProjectTags: makeListProjectTagsStub(), + createPromptsByIds: sinon.stub().resolves({ + page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'hello' }], existing_count: 0, + }), + publishProject: sinon.stub().resolves(), + }; + + const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { + prompts: [{ + text: 'hello', geoTargetId: 2840, languageCode: 'en', tagIds: ['tag-cat-1'], + }], + }, fakeLog(), undefined, { publish: false }); + + expect(result.created).to.have.lengthOf(1); + expect(transport.createPromptsByIds).to.have.been.calledOnce; + expect(transport.publishProject).to.not.have.been.called; + }); + // A name cannot identify a nested tag: the name-keyed upstream write is // root-only, so an unknown name would mint a phantom ROOT tag rather than // attach the category the caller meant. A `tags` key is therefore rejected diff --git a/test/support/serenity/handlers/publish-status.test.js b/test/support/serenity/handlers/publish-status.test.js new file mode 100644 index 000000000..48a67911e --- /dev/null +++ b/test/support/serenity/handlers/publish-status.test.js @@ -0,0 +1,171 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { use, expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +import { + PUBLISH_STATUS, + PUBLISH_OUTCOME, + readPublishStatus, + classifyPublishStatus, + pollProjectPublished, +} from '../../../../src/support/serenity/handlers/publish-status.js'; + +use(sinonChai); + +const WS = 'workspace-1'; +const PID = 'proj-1'; + +function fakeLog() { + return { + info: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), debug: sinon.stub(), + }; +} + +describe('handlers/publish-status.js (LLMO-5492 / AC3)', () => { + afterEach(() => sinon.restore()); + + describe('readPublishStatus', () => { + it('reads snake_case publish_status', () => { + expect(readPublishStatus({ publish_status: 'live' })).to.equal('live'); + }); + it('reads camelCase publishStatus as a fallback', () => { + expect(readPublishStatus({ publishStatus: 'publishing' })).to.equal('publishing'); + }); + it('returns null when absent / payload missing', () => { + expect(readPublishStatus({})).to.equal(null); + expect(readPublishStatus(null)).to.equal(null); + expect(readPublishStatus(undefined)).to.equal(null); + }); + }); + + describe('classifyPublishStatus', () => { + it('classifies live and live_with_unpublished_updates as published', () => { + expect(classifyPublishStatus({ publish_status: PUBLISH_STATUS.LIVE })) + .to.equal(PUBLISH_OUTCOME.PUBLISHED); + const lwu = { publish_status: PUBLISH_STATUS.LIVE_WITH_UNPUBLISHED_UPDATES }; + expect(classifyPublishStatus(lwu)).to.equal(PUBLISH_OUTCOME.PUBLISHED); + }); + it('classifies initial_publish_failed as failed', () => { + expect(classifyPublishStatus({ publish_status: PUBLISH_STATUS.INITIAL_PUBLISH_FAILED })) + .to.equal(PUBLISH_OUTCOME.FAILED); + }); + it('classifies draft, publishing, and unknown/absent as pending', () => { + expect(classifyPublishStatus({ publish_status: PUBLISH_STATUS.DRAFT })) + .to.equal(PUBLISH_OUTCOME.PENDING); + expect(classifyPublishStatus({ publish_status: PUBLISH_STATUS.PUBLISHING })) + .to.equal(PUBLISH_OUTCOME.PENDING); + expect(classifyPublishStatus({ publish_status: 'something_new' })) + .to.equal(PUBLISH_OUTCOME.PENDING); + expect(classifyPublishStatus({})).to.equal(PUBLISH_OUTCOME.PENDING); + }); + }); + + describe('pollProjectPublished', () => { + it('returns published on the first read when already live', async () => { + const transport = { + getProjectStatus: sinon.stub().resolves({ publish_status: 'live', published_at: 'T0' }), + }; + const result = await pollProjectPublished(transport, WS, PID); + expect(result.outcome).to.equal(PUBLISH_OUTCOME.PUBLISHED); + expect(result.status).to.equal('live'); + expect(result.publishedAt).to.equal('T0'); + expect(result.attempts).to.equal(1); + expect(transport.getProjectStatus).to.have.been.calledOnceWithExactly(WS, PID); + }); + + it('returns failed with the upstream reason on initial_publish_failed', async () => { + const transport = { + getProjectStatus: sinon.stub().resolves({ + publish_status: 'initial_publish_failed', + publishing_failed_reason: 'upstream rejected location', + }), + }; + const result = await pollProjectPublished(transport, WS, PID); + expect(result.outcome).to.equal(PUBLISH_OUTCOME.FAILED); + expect(result.failedReason).to.equal('upstream rejected location'); + }); + + it('polls until live: draft, then publishing, then live — stops on live', async () => { + const sleep = sinon.stub().resolves(); + const getProjectStatus = sinon.stub(); + getProjectStatus.onCall(0).resolves({ publish_status: 'draft' }); + getProjectStatus.onCall(1).resolves({ publish_status: 'publishing' }); + getProjectStatus.onCall(2).resolves({ publish_status: 'live' }); + + const result = await pollProjectPublished({ getProjectStatus }, WS, PID, { + attempts: 5, intervalMs: 10, sleep, + }); + + expect(result.outcome).to.equal(PUBLISH_OUTCOME.PUBLISHED); + expect(getProjectStatus).to.have.been.calledThrice; + // Slept only between reads (after attempt 1 and 2), not after the final. + expect(sleep).to.have.been.calledTwice; + }); + + it('returns pending after exhausting attempts when never live (worker reconciles)', async () => { + const sleep = sinon.stub().resolves(); + const transport = { getProjectStatus: sinon.stub().resolves({ publish_status: 'publishing' }) }; + + const result = await pollProjectPublished(transport, WS, PID, { + attempts: 3, intervalMs: 5, sleep, + }); + + expect(result.outcome).to.equal(PUBLISH_OUTCOME.PENDING); + expect(result.status).to.equal('publishing'); + expect(transport.getProjectStatus).to.have.been.calledThrice; + expect(result.attempts).to.equal(3); + }); + + it('treats a status-read error as non-fatal, logs a warning, and reports pending', async () => { + const log = fakeLog(); + const transport = { getProjectStatus: sinon.stub().rejects(new Error('read 500')) }; + + const result = await pollProjectPublished(transport, WS, PID, { + attempts: 2, sleep: sinon.stub().resolves(), log, + }); + + // poll itself never throws; the read error is surfaced, not propagated. + expect(result.outcome).to.equal(PUBLISH_OUTCOME.PENDING); + expect(result.error).to.equal('read 500'); + expect(transport.getProjectStatus).to.have.been.calledTwice; + expect(log.warn).to.have.been.called; + }); + + it('does not sleep when only a single attempt is requested', async () => { + const sleep = sinon.stub().resolves(); + const transport = { getProjectStatus: sinon.stub().resolves({ publish_status: 'draft' }) }; + + await pollProjectPublished(transport, WS, PID, { attempts: 1, intervalMs: 1000, sleep }); + + expect(sleep).to.have.callCount(0); + }); + + it('uses the real setTimeout-backed delay when no sleep is injected', async () => { + // No `sleep` override → exercises the default setTimeout-backed delay + // between reads. Tiny interval keeps the test fast; draft→live across two + // attempts forces exactly one real delay. + const getProjectStatus = sinon.stub(); + getProjectStatus.onFirstCall().resolves({ publish_status: 'draft' }); + getProjectStatus.onSecondCall().resolves({ publish_status: 'live' }); + + const result = await pollProjectPublished({ getProjectStatus }, WS, PID, { + attempts: 2, intervalMs: 1, + }); + + expect(result.outcome).to.equal('published'); + expect(result.attempts).to.equal(2); + }); + }); +}); diff --git a/test/support/serenity/rest-transport.test.js b/test/support/serenity/rest-transport.test.js index ed7cd0c67..10ba6b864 100644 --- a/test/support/serenity/rest-transport.test.js +++ b/test/support/serenity/rest-transport.test.js @@ -985,6 +985,27 @@ describe('Semrush REST transport', () => { }); }); + describe('getProjectStatus', () => { + // LLMO-5492 / AC3 — the publish-completion read. Uses the draft view + // (draft=true) so a never-published project's `publish_status` is echoed + // faithfully (the live view empties a never-published draft, serenity-docs #12 §10). + it('GETs /v1/workspaces/{ws}/projects/{pid} with draft=true and no body', async () => { + fetchStub.resolves(fetchOk({ id: PROJECT_ID, publish_status: 'live' })); + const transport = createSerenityTransport({ env: TEST_ENV, imsToken: IMS }); + + const out = await transport.getProjectStatus(WORKSPACE_ID, PROJECT_ID); + + const call = await callOf(fetchStub); + expect(call.method).to.equal('GET'); + expect(call.url).to.match( + new RegExp(`/v1/workspaces/${WORKSPACE_ID}/projects/${PROJECT_ID}\\?`), + ); + expect(call.url).to.include('draft=true'); + expect(call.body).to.equal(undefined); + expect(out.publish_status).to.equal('live'); + }); + }); + describe('listAiModels', () => { it('GETs /v1/.../ai_models with page=1&limit=100 by default', async () => { fetchStub.resolves(fetchOk({ items: [] }));