Skip to content

Commit df6f629

Browse files
Andreea Stroeclaude
authored andcommitted
feat(serenity): confirm publish completion via publish_status poll (LLMO-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>
1 parent 7418da7 commit df6f629

6 files changed

Lines changed: 538 additions & 1 deletion

File tree

src/support/serenity/handlers/finalize.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { hasText } from '@adobe/spacecat-shared-utils';
1515
import { ErrorWithStatusCode } from '../../utils.js';
1616
import { handleCreatePrompts } from './prompts.js';
1717
import { handleUpdateModels } from './markets.js';
18+
import { pollProjectPublished, PUBLISH_OUTCOME } from './publish-status.js';
1819

1920
/**
2021
* LLMO-5492 — publish-after-populate finalize step.
@@ -49,6 +50,11 @@ import { handleUpdateModels } from './markets.js';
4950
* @param {string} semrushWorkspaceId - Org's Semrush workspace id.
5051
* @param {object} body - { prompts?: Array, models?: Array<{geoTargetId, languageCode, modelIds}> }
5152
* @param {object} [log] - Logger.
53+
* @param {object} [options] - Publish-confirm tuning (AC3).
54+
* @param {number} [options.confirmAttempts=1] - Max getProjectStatus reads per
55+
* project after publish (bounded to the Lambda budget; the unbounded ≤900s
56+
* reconcile loop is the DRS/worker's job, not this Lambda's).
57+
* @param {number} [options.confirmIntervalMs=0] - Delay between confirm reads.
5258
* @returns {Promise<{prompts: object, models: Array, published: string[], publishFailed: Array}>}
5359
*/
5460
export async function finalizeSerenityProjects(
@@ -58,6 +64,7 @@ export async function finalizeSerenityProjects(
5864
semrushWorkspaceId,
5965
body,
6066
log,
67+
options = {},
6168
) {
6269
if (!hasText(brandId)) {
6370
throw new ErrorWithStatusCode('brandId is required', 400);
@@ -149,20 +156,63 @@ export async function finalizeSerenityProjects(
149156
const projectIds = [...new Set(
150157
rows.map((r) => r.getSemrushProjectId()).filter((id) => hasText(id)),
151158
)];
159+
// AC3: publish is async (202) with no completion webhook. After the publish
160+
// is accepted we do a BOUNDED, best-effort confirm via the project's
161+
// `publish_status` (handlers/publish-status.js). The bound stays inside the
162+
// Lambda wall budget — the unbounded ≤900s reconcile poll is the DRS/worker's
163+
// job. Confirm semantics:
164+
// - confirmed live → published
165+
// - initial_publish_failed → publishFailed (terminal, surfaced early)
166+
// - still draft/publishing/unknown within budget, OR status unreadable
167+
// → published (accepted; the worker
168+
// reconciles) — we do NOT mislabel an
169+
// in-progress async publish as a failure.
170+
const { confirmAttempts = 1, confirmIntervalMs = 0 } = options;
171+
const canConfirm = typeof transport.getProjectStatus === 'function';
152172
const published = [];
153173
const publishFailed = [];
154174
for (const projectId of projectIds) {
155175
try {
156176
// eslint-disable-next-line no-await-in-loop
157177
await transport.publishProject(semrushWorkspaceId, projectId);
158-
published.push(projectId);
159178
} catch (e) {
160179
log?.error?.('finalizeSerenityProjects: publish failed', {
161180
brandId,
162181
projectId,
163182
error: e.message,
164183
});
165184
publishFailed.push({ projectId, error: e.message });
185+
// eslint-disable-next-line no-continue
186+
continue;
187+
}
188+
189+
if (!canConfirm) {
190+
published.push(projectId);
191+
// eslint-disable-next-line no-continue
192+
continue;
193+
}
194+
195+
// eslint-disable-next-line no-await-in-loop
196+
const confirm = await pollProjectPublished(transport, semrushWorkspaceId, projectId, {
197+
attempts: confirmAttempts,
198+
intervalMs: confirmIntervalMs,
199+
log,
200+
});
201+
if (confirm.outcome === PUBLISH_OUTCOME.FAILED) {
202+
log?.error?.('finalizeSerenityProjects: publish reported failed by upstream', {
203+
brandId,
204+
projectId,
205+
publishStatus: confirm.status,
206+
failedReason: confirm.failedReason,
207+
});
208+
publishFailed.push({
209+
projectId,
210+
error: confirm.failedReason || confirm.status || 'initial_publish_failed',
211+
publishStatus: confirm.status,
212+
});
213+
} else {
214+
// published (confirmed live) or pending (accepted, worker reconciles).
215+
published.push(projectId);
166216
}
167217
}
168218

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
/**
14+
* LLMO-5492 / AC3 — publish-completion status reading.
15+
*
16+
* Semrush publishes a project asynchronously: POST .../publish returns 202 and
17+
* the project's `publish_status` attribute transitions in the background, with
18+
* no completion webhook. Completion is therefore observed by re-reading the
19+
* project (transport.getProjectStatus) and inspecting `publish_status`.
20+
*
21+
* This module is the reusable read/classify/poll mechanism. Two consumers:
22+
* - finalize.js does a BOUNDED, best-effort confirm within the Lambda's wall
23+
* budget (one or a few reads) to catch an early hard failure.
24+
* - the DRS/audit worker (a separate repo, SQS-driven, ≤900s) drives the
25+
* UNBOUNDED long-poll using the same classify logic.
26+
*
27+
* Enum (serenity-docs §6):
28+
* draft | publishing | initial_publish_failed | live | live_with_unpublished_updates
29+
*/
30+
31+
export const PUBLISH_STATUS = {
32+
DRAFT: 'draft',
33+
PUBLISHING: 'publishing',
34+
INITIAL_PUBLISH_FAILED: 'initial_publish_failed',
35+
LIVE: 'live',
36+
LIVE_WITH_UNPUBLISHED_UPDATES: 'live_with_unpublished_updates',
37+
};
38+
39+
// "Published" = the project is live to consumers, whether or not it carries
40+
// later unpublished draft edits.
41+
const PUBLISHED_STATES = new Set([
42+
PUBLISH_STATUS.LIVE,
43+
PUBLISH_STATUS.LIVE_WITH_UNPUBLISHED_UPDATES,
44+
]);
45+
46+
// Terminal failure of the FIRST publish. `publishing` is in-progress (not a
47+
// failure); only `initial_publish_failed` is terminal-bad.
48+
const FAILED_STATES = new Set([PUBLISH_STATUS.INITIAL_PUBLISH_FAILED]);
49+
50+
/**
51+
* Outcome buckets returned by classifyPublishStatus / pollProjectPublished.
52+
* `pending` covers draft, publishing, and any unknown/absent status — i.e.
53+
* "not yet confirmed live, not yet confirmed failed".
54+
*/
55+
export const PUBLISH_OUTCOME = {
56+
PUBLISHED: 'published',
57+
FAILED: 'failed',
58+
PENDING: 'pending',
59+
};
60+
61+
const DEFAULT_ATTEMPTS = 1;
62+
const DEFAULT_INTERVAL_MS = 0;
63+
64+
const defaultSleep = (ms) => new Promise((resolve) => {
65+
setTimeout(resolve, ms);
66+
});
67+
68+
/**
69+
* Reads `publish_status` off a project payload, tolerating both the upstream
70+
* snake_case form and a normalised camelCase form.
71+
* @param {object} project - Raw project JSON from getProjectStatus.
72+
* @returns {string|null}
73+
*/
74+
export function readPublishStatus(project) {
75+
return project?.publish_status ?? project?.publishStatus ?? null;
76+
}
77+
78+
/**
79+
* Maps a project payload to a PUBLISH_OUTCOME.
80+
* @param {object} project - Raw project JSON from getProjectStatus.
81+
* @returns {'published'|'failed'|'pending'}
82+
*/
83+
export function classifyPublishStatus(project) {
84+
const status = readPublishStatus(project);
85+
if (PUBLISHED_STATES.has(status)) {
86+
return PUBLISH_OUTCOME.PUBLISHED;
87+
}
88+
if (FAILED_STATES.has(status)) {
89+
return PUBLISH_OUTCOME.FAILED;
90+
}
91+
return PUBLISH_OUTCOME.PENDING;
92+
}
93+
94+
/**
95+
* Polls getProjectStatus until the project is confirmed live or confirmed
96+
* failed, or the attempt budget is exhausted. A status-read error is non-fatal
97+
* to the poll (logged, retried, and reported as `pending` if it persists) —
98+
* the caller decides how to treat an unconfirmed publish.
99+
*
100+
* Bounded by `attempts`/`intervalMs`. finalize uses a tiny budget (the long
101+
* ≤900s reconcile loop is the worker's job); the worker passes a large one.
102+
*
103+
* @param {object} transport - Serenity REST transport (needs getProjectStatus).
104+
* @param {string} semrushWorkspaceId
105+
* @param {string} projectId
106+
* @param {object} [opts]
107+
* @param {number} [opts.attempts=1] - Max status reads (>=1).
108+
* @param {number} [opts.intervalMs=0] - Delay between reads.
109+
* @param {object} [opts.log] - Logger.
110+
* @param {function} [opts.sleep] - Injectable delay (tests pass a no-op).
111+
* @returns {Promise<{outcome:string, status:(string|null), publishedAt:(string|null),
112+
* failedReason:(string|null), attempts:number, error?:string}>}
113+
*/
114+
export async function pollProjectPublished(
115+
transport,
116+
semrushWorkspaceId,
117+
projectId,
118+
{
119+
attempts = DEFAULT_ATTEMPTS,
120+
intervalMs = DEFAULT_INTERVAL_MS,
121+
log,
122+
sleep = defaultSleep,
123+
} = {},
124+
) {
125+
const total = Math.max(1, attempts);
126+
let last = {
127+
outcome: PUBLISH_OUTCOME.PENDING,
128+
status: null,
129+
publishedAt: null,
130+
failedReason: null,
131+
attempts: 0,
132+
};
133+
134+
for (let i = 0; i < total; i += 1) {
135+
let project;
136+
let readError;
137+
try {
138+
// eslint-disable-next-line no-await-in-loop
139+
project = await transport.getProjectStatus(semrushWorkspaceId, projectId);
140+
} catch (e) {
141+
readError = e;
142+
log?.warn?.('pollProjectPublished: status read failed', {
143+
projectId, attempt: i + 1, error: e.message,
144+
});
145+
}
146+
147+
if (!readError) {
148+
const status = readPublishStatus(project);
149+
last = {
150+
outcome: classifyPublishStatus(project),
151+
status,
152+
publishedAt: project?.published_at ?? project?.publishedAt ?? null,
153+
failedReason: project?.publishing_failed_reason ?? project?.publishingFailedReason ?? null,
154+
attempts: i + 1,
155+
};
156+
if (last.outcome !== PUBLISH_OUTCOME.PENDING) {
157+
return last;
158+
}
159+
} else {
160+
last = { ...last, attempts: i + 1, error: readError.message };
161+
}
162+
163+
if (i < total - 1 && intervalMs > 0) {
164+
// eslint-disable-next-line no-await-in-loop
165+
await sleep(intervalMs);
166+
}
167+
}
168+
169+
return last;
170+
}

src/support/serenity/rest-transport.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,25 @@ export function createSerenityTransport({ env, imsToken }) {
223223
return request('POST', url, imsToken, undefined);
224224
},
225225

226+
/**
227+
* GET /v1/workspaces/{ws}/projects/{pid} — fetches a single project,
228+
* including its `publish_status` attribute. Publish is asynchronous and
229+
* Semrush emits no completion webhook, so publish completion is observed by
230+
* re-reading this resource (see handlers/publish-status.js for the
231+
* classify + poll helpers built on top of this).
232+
*
233+
* Uses the v1 DEFAULT view (no `live` query param) deliberately: the v2
234+
* list and the v1 `live=true` view return a live-view materialisation that
235+
* defaults the location and empties config for a never-published draft,
236+
* whereas the v1 default view echoes the draft's real settings and
237+
* `publish_status` faithfully (verified against the tenant, serenity-docs
238+
* §10). Returns the raw project JSON.
239+
*/
240+
async getProjectStatus(semrushWorkspaceId, projectId) {
241+
const url = `${root}${API_PREFIX}/v1/workspaces/${enc(semrushWorkspaceId)}/projects/${enc(projectId)}`;
242+
return request('GET', url, imsToken, undefined);
243+
},
244+
226245
/**
227246
* GET /v1/workspaces/{ws}/projects/{pid}/ai_models — list AI models
228247
* configured for a project. `model.key` is the value the Reporting API

0 commit comments

Comments
 (0)