Skip to content
Merged
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
9 changes: 8 additions & 1 deletion src/support/serenity/handlers/markets-subworkspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ function validateCreateBody(body) {
async function generateAndAttachPrompts(transport, workspaceId, projectId, {
domain, country, topicCap = 0, brandNames = [], provisioned, env,
writeDeadline = computeWriteDeadline(),
}, log) {
}, log, headroom) {
const raw = await transport.getBrandTopics(workspaceId, { domain, country });
let topics = [];
if (Array.isArray(raw)) {
Expand Down Expand Up @@ -313,6 +313,13 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, {
}
}

// PROMPT metering seam (Rainer, live-verified LLMO-6190): front headroom sized
// on the real generated prompt count (`texts.size`) BEFORE the metered
// `createPromptsByIds` writes below — the disguised-quota 405 fires there, not
// at publish. No-op when the flag is OFF; NOT optional-chained, a caller that
// forgets to thread the guard must fail loud.
await headroom.ensure({ prompts: texts.size }, { includeDrafted: true });

for (const { items, tagIds } of byTagSet.values()) {
// eslint-disable-next-line no-await-in-loop
await transport.createPromptsByIds(workspaceId, projectId, items, tagIds);
Expand Down
25 changes: 10 additions & 15 deletions src/support/serenity/handlers/prompts-subworkspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,20 +242,6 @@ export async function handleCreatePromptsSubworkspace(
invalidateTagCacheForProject(workspaceId, pid);
}

// PROMPT metering seam: the just-created prompts are drafted synchronously across the affected
// projects of THIS child; size headroom from `used + drafted` (includeDrafted, staleness-immune)
// before the publish. One workspace-level top-up covers all affected projects (the allocation is
// per sub-workspace, not per project). No-op when the flag is OFF; skipped when nothing was
// created so the OFF path and the empty path issue zero headroom reads.
if (affectedProjectIds.length > 0) {
const headroom = createHeadroomGuard(
transport,
{ enabled: dynamicAllocation, subWorkspaceId: workspaceId, parentWorkspaceId },
log,
);
await headroom.ensure({}, { includeDrafted: true });
}

if (deferPublish) {
log?.info?.('serenity create-prompts (subworkspace): deferPublish set — prompts written as draft, publish skipped', {
workspaceId, created: created.length, skipped: skipped.length, failed: failed.length,
Expand All @@ -265,7 +251,16 @@ export async function handleCreatePromptsSubworkspace(
};
}

const publishErrors = await publishAffected(transport, workspaceId, affectedProjectIds, log);
// Route each project's publish through the headroom guard's retryOnQuota (LLMO-6190 item 4):
// a disguised metered-405 gets ONE bounded top-up+retry per project before being recorded as a
// failure. No-op passthrough when the flag is OFF (the guard's retryOnQuota is a plain call).
const publishErrors = await publishAffected(
transport,
workspaceId,
affectedProjectIds,
log,
(fn) => headroom.retryOnQuota(fn),
);
// publishAffected returns { projectId, message } records whose message is
// ALREADY redacted (redactUpstreamMessage) — pubErr is a record, not a raw error.
for (const pubErr of publishErrors) {
Expand Down
28 changes: 24 additions & 4 deletions src/support/serenity/handlers/prompts.js
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,18 @@ export function parseUpdatePromptBody(body) {
},
};
}
const text = String(body.text);
// Mirror the create contract (`normalizePromptInput`): empty or whitespace-only
// text is rejected here rather than passed on to `renamePrompt`, where it would
// be classified and written as a blank prompt. `|| ''` also coerces a falsy
// non-string (`null`, `0`, `false`) to empty, matching create exactly.
const text = String(body.text || '').trim();
if (!text) {
return {
ok: false,
status: 400,
body: { error: 'invalidRequest', message: 'text must be a non-empty string' },
};
}
const tagIds = sanitizeTagIds(body.tagIds);
if (tagIds.length === 0) {
return {
Expand Down Expand Up @@ -819,10 +830,19 @@ export async function handleUpdatePrompt(
}
const projectId = project.getSemrushProjectId();

// Recompute the type AND intent tags from the NEW text BEFORE the delete: the
// unified layer (tree read / on-demand tag create / LLM classify) must not run
// between delete and create, so a classification failure aborts cleanly with
// Recompute the type AND intent tags from the NEW text BEFORE the rename: the
// unified layer (tree read / on-demand tag create / LLM classify) must run
// before any upstream write, so a classification failure aborts cleanly with
// the old prompt still present (serenity-docs#31, #32).
//
// This runs UNCONDITIONALLY, even when the PATCH does not change the text. The
// upstream provider has no GET-by-id and the handler is not sent the old text
// (the body is the full next state — see the docblock above), so it cannot
// know whether the text actually changed. `renamePrompt`'s `is_updated: false`
// reports a no-op only AFTER the rename, too late to gate a classify that has
// to run first for failure-safety. Skipping the reclassification would require
// the client to send the old text — a contract change deliberately out of
// scope here (keep the edit path a single straight line).
const injectComputedType = makeTypeInjector(
transport,
semrushWorkspaceId,
Expand Down
2 changes: 2 additions & 0 deletions test/support/serenity/dynamic-allocation-fronting.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,8 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => {
},
log,
undefined,
undefined,
undefined,
{ dynamicAllocation: true, parentWorkspaceId: MASTER },
);
// The retry succeeded, so the create is NOT recorded as a publish failure.
Expand Down
28 changes: 21 additions & 7 deletions test/support/serenity/handlers/prompts-subworkspace.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ describe('prompts-subworkspace handlers', () => {
prompts: [{
text: 'p', tagIds: ['tag-1'], geoTargetId: 2840, languageCode: 'en',
}],
}, log, undefined, { dynamicAllocation: true, parentWorkspaceId: 'parent-ws' });
}, log, undefined, undefined, undefined, {
dynamicAllocation: true, parentWorkspaceId: 'parent-ws',
});
expect(result.created).to.have.length(1);
expect(transport.getWorkspaceResources).to.have.been.calledOnceWith(WS);
expect(transport.getWorkspaceResources)
Expand Down Expand Up @@ -339,7 +341,7 @@ describe('prompts-subworkspace handlers', () => {
expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly(
WS,
'p-us-en',
[{ id: 'old-id', references: ['tag-1'], replace: true }],
[{ id: 'old-id', references: ['tag-1', TAG_IDS.intentInformational], replace: true }],
);
expect(transport.deletePromptsByIds).to.not.have.been.called;
expect(transport.createPromptsByIds).to.not.have.been.called;
Expand Down Expand Up @@ -398,10 +400,15 @@ describe('prompts-subworkspace handlers', () => {
text: 'new', tagIds: ['tag-cat-1', '', undefined], geoTargetId: 2840, languageCode: 'en',
}, log);
expect(result.status).to.equal(200);
expect(result.body.semrushPromptId).to.equal('new-prompt-by-id');
// The id is preserved — the edit is in place, never a re-create.
expect(result.body.semrushPromptId).to.equal('old-id');
expect(result.body.tagIds).to.deep.equal(['tag-cat-1', TAG_IDS.intentInformational]);
expect(transport.deletePromptsByIds).to.have.been.calledWith(WS, 'p-us-en', ['old-id']);
expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['new'], ['tag-cat-1', TAG_IDS.intentInformational]);
expect(transport.renamePrompt).to.have.been.calledOnceWithExactly(WS, 'p-us-en', 'old-id', 'new');
expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly(
WS,
'p-us-en',
[{ id: 'old-id', references: ['tag-cat-1', TAG_IDS.intentInformational], replace: true }],
);
});

it('400s when the slice key is invalid', async () => {
Expand Down Expand Up @@ -436,8 +443,15 @@ describe('prompts-subworkspace handlers', () => {
expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly(
WS,
'p-us-en',
['now mentions Acme'],
[TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.intentInformational],
[{
id: 'old-id',
references: [
TAG_IDS.categoryRunningShoes,
TAG_IDS.typeBranded,
TAG_IDS.intentInformational,
],
replace: true,
}],
);
});

Expand Down
Loading
Loading