Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions src/support/serenity/brand-provisioning.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions src/support/serenity/defer-publish-active.js
Original file line number Diff line number Diff line change
@@ -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/<env>/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';
}
4 changes: 4 additions & 0 deletions src/support/serenity/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading