diff --git a/templates/content/actions/_builder-cms-read-client.test.ts b/templates/content/actions/_builder-cms-read-client.test.ts index 4663110853..48e7f9e56d 100644 --- a/templates/content/actions/_builder-cms-read-client.test.ts +++ b/templates/content/actions/_builder-cms-read-client.test.ts @@ -4,8 +4,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { builderBlocksHash, builderEntryBlocks } from "../shared/builder-mdx"; import { builderCmsListEntryFields, + BuilderCmsContentEntryReadError, listBuilderCmsModels, readBuilderCmsContentEntry, + readBuilderCmsContentEntryResult, readBuilderCmsContentEntries, readBuilderCmsEntryLiveState, readBuilderCmsModelFields, @@ -916,6 +918,60 @@ describe("Builder CMS read client", () => { } }); + it("distinguishes a provider-confirmed empty entry from a missing entry", async () => { + process.env.BUILDER_CONTENT_API_HOST = "https://cdn.test.builder.io"; + resolveBuilderCredentialMock.mockResolvedValue("public-key"); + + const found = await readBuilderCmsContentEntryResult({ + model: "blog_article", + entryId: "empty-entry", + fetchImpl: vi.fn( + async () => + new Response( + JSON.stringify({ + id: "empty-entry", + data: { title: "Intentionally empty", blocks: [] }, + }), + { status: 200 }, + ), + ) as unknown as typeof fetch, + }); + const missing = await readBuilderCmsContentEntryResult({ + model: "blog_article", + entryId: "missing-entry", + fetchImpl: vi.fn( + async () => new Response(null, { status: 404 }), + ) as unknown as typeof fetch, + }); + + expect(found).toMatchObject({ state: "found", providerStatus: "http_200" }); + expect(found.entry?.rawEntry?.data?.blocks).toEqual([]); + expect(missing).toEqual({ + state: "not_found", + entry: null, + providerStatus: "http_404", + }); + }); + + it("preserves actionable retry evidence for Builder read failures", async () => { + process.env.BUILDER_CONTENT_API_HOST = "https://cdn.test.builder.io"; + resolveBuilderCredentialMock.mockResolvedValue("public-key"); + + await expect( + readBuilderCmsContentEntryResult({ + model: "blog_article", + entryId: "rate-limited-entry", + fetchImpl: vi.fn( + async () => new Response(null, { status: 429 }), + ) as unknown as typeof fetch, + }), + ).rejects.toMatchObject>({ + reason: "transient_read_failure", + providerStatus: "http_429", + retryable: true, + }); + }); + it("can return an initial partial Builder Content API page for fast refresh", async () => { process.env.BUILDER_CONTENT_API_HOST = "https://cdn.test.builder.io"; resolveBuilderCredentialMock.mockImplementation(async (key) => diff --git a/templates/content/actions/_builder-cms-read-client.ts b/templates/content/actions/_builder-cms-read-client.ts index 155524820d..75e7473c6c 100644 --- a/templates/content/actions/_builder-cms-read-client.ts +++ b/templates/content/actions/_builder-cms-read-client.ts @@ -151,6 +151,34 @@ export function summarizeBuilderCmsEntryFidelity( type FetchLike = typeof fetch; +export type BuilderCmsContentEntryReadResult = + | { + state: "found"; + entry: BuilderCmsSourceEntry; + providerStatus: "http_200"; + } + | { + state: "not_found"; + entry: null; + providerStatus: "http_404" | "http_200_unexpected_entry"; + }; + +export class BuilderCmsContentEntryReadError extends Error { + constructor( + message: string, + readonly reason: + | "auth_failed" + | "access_denied" + | "transient_read_failure" + | "malformed_body", + readonly providerStatus: string, + readonly retryable: boolean, + ) { + super(message); + this.name = "BuilderCmsContentEntryReadError"; + } +} + type BuilderMcpContentPart = { type?: string; text?: string; @@ -1190,10 +1218,26 @@ export async function readBuilderCmsContentEntry(args: { entryId: string; fetchImpl?: FetchLike; }): Promise { + const result = await readBuilderCmsContentEntryResult({ + ...args, + strictEntryIdentity: false, + }); + return result.entry; +} + +export async function readBuilderCmsContentEntryResult(args: { + model: string; + entryId: string; + fetchImpl?: FetchLike; + strictEntryIdentity?: boolean; +}): Promise { const publicKey = await resolveBuilderCredential("BUILDER_PUBLIC_KEY"); if (!publicKey) { - throw new Error( + throw new BuilderCmsContentEntryReadError( "Builder CMS entry read skipped because BUILDER_PUBLIC_KEY is not configured.", + "auth_failed", + "credential_missing", + false, ); } @@ -1205,23 +1249,78 @@ export async function readBuilderCmsContentEntry(args: { ); applyBuilderCmsBodyEntryReadParams(url, publicKey); - const response = await fetchBuilderContentPage({ - fetchImpl: args.fetchImpl ?? fetch, - url, - }); - if (response.status === 404) return null; + let response: Response; + try { + response = await fetchBuilderContentPage({ + fetchImpl: args.fetchImpl ?? fetch, + url, + }); + } catch (error) { + if (error instanceof BuilderCmsContentEntryReadError) throw error; + throw new BuilderCmsContentEntryReadError( + `Builder CMS entry read failed before a response was received: ${ + error instanceof Error ? error.message : String(error) + }`, + "transient_read_failure", + "network_error", + true, + ); + } + if (response.status === 404) { + return { + state: "not_found", + entry: null, + providerStatus: "http_404", + }; + } if (!response.ok) { - throw new Error( + const reason = + response.status === 401 + ? "auth_failed" + : response.status === 403 + ? "access_denied" + : response.status === 429 || response.status >= 500 + ? "transient_read_failure" + : "malformed_body"; + throw new BuilderCmsContentEntryReadError( `Builder CMS entry read failed with HTTP ${response.status}.`, + reason, + `http_${response.status}`, + reason === "transient_read_failure", ); } - const json = (await response.json()) as unknown; + let json: unknown; + try { + json = (await response.json()) as unknown; + } catch { + throw new BuilderCmsContentEntryReadError( + "Builder CMS entry read returned malformed JSON.", + "malformed_body", + "http_200_invalid_json", + false, + ); + } const rawEntry = Array.isArray(json) ? json[0] : (entryArrayFromResponse(json)[0] ?? json); const entry = normalizeBuilderCmsApiEntry(rawEntry, args.model); - return entry?.id === args.entryId ? entry : null; + if (!entry || entry.id !== args.entryId) { + if (args.strictEntryIdentity === false) { + return { + state: "not_found", + entry: null, + providerStatus: "http_200_unexpected_entry", + }; + } + throw new BuilderCmsContentEntryReadError( + "Builder CMS entry read returned an unexpected entry payload.", + "malformed_body", + "http_200_unexpected_entry", + false, + ); + } + return { state: "found", entry, providerStatus: "http_200" }; } export async function listBuilderCmsModels( diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 3dd129f08b..73c1bee2ba 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -23,6 +23,7 @@ import { builderBodyChangeForUnsourcedLocalCreate, builderBodyHydrationPriorityForRequest, builderBodyHydrationAttemptIsTerminal, + builderBodyHydrationNextAttemptAt, builderBodyNeedsSourceComponentWrite, knownBuilderReviewDocumentIds, builderSourcePropertyAssignments, @@ -875,6 +876,16 @@ describe("database source helpers", () => { expect(builderBodyHydrationAttemptIsTerminal(5)).toBe(true); }); + it("backs Builder body retries off without exceeding five minutes", () => { + const attemptedAt = "2026-08-21T12:00:00.000Z"; + expect(builderBodyHydrationNextAttemptAt(1, attemptedAt)).toBe( + "2026-08-21T12:00:30.000Z", + ); + expect(builderBodyHydrationNextAttemptAt(5, attemptedAt)).toBe( + "2026-08-21T12:05:00.000Z", + ); + }); + it("prioritizes opened Builder body hydration ahead of background work", () => { expect( builderBodyHydrationPriorityForRequest({ documentId: "doc-open" }), diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 7fc7c254ef..e75225c88d 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -1,11 +1,13 @@ import { createHash } from "node:crypto"; import { getDialect, type Dialect } from "@agent-native/core/db"; -import { and, asc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm"; +import { isFeatureFlagEnabled } from "@agent-native/core/feature-flags"; +import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import type { ContentDatabase, + ContentDatabaseBodyHydration, ContentDatabaseBodyHydrationSummary, ContentDatabaseItem, ContentDatabaseSource, @@ -63,8 +65,10 @@ import { localFolderSourceIdentityFromMetadata, } from "./_local-folder-source.js"; export { bulkChunkSizeForColumnCount } from "./_batch-utils.js"; +import { BUILDER_BODY_HYDRATION_REASONS_FLAG } from "../shared/feature-flags.js"; import { - readBuilderCmsContentEntry, + BuilderCmsContentEntryReadError, + readBuilderCmsContentEntryResult, readBuilderCmsContentEntries, readBuilderCmsModelFields, type BuilderCmsReadProgress, @@ -1000,6 +1004,38 @@ const BUILDER_BODY_HYDRATION_CODEC_VERSION = const BUILDER_CMS_REFRESH_INITIAL_PAGES = 1; const BUILDER_BODY_NOT_AVAILABLE_ERROR = "body not yet available from Builder"; +class BuilderBodyHydrationError extends Error { + constructor( + message: string, + readonly reason: "not_found" | "unsupported_content" | "conversion_failed", + readonly providerStatus: string, + readonly retryable: boolean, + ) { + super(message); + this.name = "BuilderBodyHydrationError"; + } +} + +function builderBodyHydrationFailureEvidence(error: unknown) { + if ( + error instanceof BuilderCmsContentEntryReadError || + error instanceof BuilderBodyHydrationError + ) { + return { + reason: error.reason, + providerStatus: error.providerStatus, + retryable: error.retryable, + message: error.message, + } as const; + } + return { + reason: "conversion_failed" as const, + providerStatus: "local_conversion", + retryable: false, + message: error instanceof Error ? error.message : String(error), + }; +} + function idChunkSize() { return bulkChunkSizeForColumnCount(1); } @@ -1040,6 +1076,15 @@ export function builderBodyHydrationAttemptIsTerminal(attempts: number) { return attempts >= BUILDER_BODY_HYDRATION_MAX_ATTEMPTS; } +export function builderBodyHydrationNextAttemptAt( + attempts: number, + attemptedAt: string, +) { + const base = Date.parse(attemptedAt); + const delayMs = Math.min(30_000 * 2 ** Math.max(0, attempts - 1), 5 * 60_000); + return new Date(base + delayMs).toISOString(); +} + async function builderBodySnapshotForEntry(entry: BuilderCmsSourceEntry) { if (!entry.rawEntry) return null; const [readableBundle, losslessBundle] = await Promise.all([ @@ -1364,6 +1409,23 @@ function builderEntryFromSourceRow(args: { }; } +type BuilderLiveBodyReadResult = + | { + state: "body"; + entry: BuilderCmsSourceEntry; + providerStatus: "http_200"; + } + | { + state: "empty_body"; + entry: BuilderCmsSourceEntry; + providerStatus: "http_200"; + } + | { + state: "not_found"; + entry: null; + providerStatus: "http_404" | "http_200_unexpected_entry"; + }; + async function readBuilderEntryWithLiveBodyFromSourceRow(args: { row: Pick< ContentDatabaseSourceRecordRowDb, @@ -1371,16 +1433,19 @@ async function readBuilderEntryWithLiveBodyFromSourceRow(args: { >; sourceTable: string; fallbackTitle: string; -}): Promise { + reasonedOutcomesEnabled?: boolean; +}): Promise { const sourceValues = parseObject>( args.row.sourceValuesJson, ) ?? {}; - const liveEntry = await readBuilderCmsContentEntry({ + const liveRead = await readBuilderCmsContentEntryResult({ model: args.sourceTable, entryId: args.row.sourceRowId, + strictEntryIdentity: args.reasonedOutcomesEnabled === true, }); - if (!liveEntry || liveEntry.id !== args.row.sourceRowId) return null; + if (liveRead.state === "not_found") return liveRead; + const liveEntry = liveRead.entry; const entryWithStoredValues = { ...liveEntry, title: liveEntry.title || args.fallbackTitle, @@ -1396,7 +1461,68 @@ async function readBuilderEntryWithLiveBodyFromSourceRow(args: { const refreshedEntry = await withBuilderBodySourceValues( entryWithStoredValues, ); - return builderEntryHasBodyContent(refreshedEntry) ? refreshedEntry : null; + if (builderEntryHasBodyContent(refreshedEntry)) { + return { state: "body", entry: refreshedEntry, providerStatus: "http_200" }; + } + if (args.reasonedOutcomesEnabled !== true) { + return { + state: "empty_body", + entry: refreshedEntry, + providerStatus: "http_200", + }; + } + const rawData = liveEntry.rawEntry?.data; + const rawBlocks = rawData?.blocks; + const rawBlocksString = rawData?.blocksString; + if (rawBlocks !== undefined && !Array.isArray(rawBlocks)) { + throw new BuilderCmsContentEntryReadError( + "Builder CMS entry read returned a malformed blocks field.", + "malformed_body", + "http_200_invalid_blocks", + false, + ); + } + if (rawBlocksString !== undefined && typeof rawBlocksString !== "string") { + throw new BuilderCmsContentEntryReadError( + "Builder CMS entry read returned a malformed blocksString field.", + "malformed_body", + "http_200_invalid_blocks_string", + false, + ); + } + if (typeof rawBlocksString === "string" && rawBlocksString.trim()) { + try { + if (!Array.isArray(JSON.parse(rawBlocksString))) throw new Error(); + } catch { + throw new BuilderCmsContentEntryReadError( + "Builder CMS entry read returned a malformed blocksString field.", + "malformed_body", + "http_200_invalid_blocks_string", + false, + ); + } + } + if (rawBlocks === undefined && rawBlocksString === undefined) { + throw new BuilderCmsContentEntryReadError( + "Builder CMS entry read did not include an authoritative body field.", + "malformed_body", + "http_200_missing_body", + false, + ); + } + if (liveEntry.rawEntry && builderEntryBlocks(liveEntry.rawEntry).length > 0) { + throw new BuilderBodyHydrationError( + "Builder returned body blocks that the Content converter could not hydrate.", + "unsupported_content", + "http_200_unsupported_blocks", + false, + ); + } + return { + state: "empty_body", + entry: refreshedEntry, + providerStatus: "http_200", + }; } export async function enqueueBuilderBodyHydration(args: { @@ -1482,6 +1608,11 @@ async function enqueueBuilderBodyHydrations( const shouldPreserveExistingEntry = builderEntryHasBodyContent(existingEntry) && !builderEntryHasBodyContent(request.entry); + const requestEntryJson = JSON.stringify(request.entry); + const sourceEntryChanged = + !!existing && + !shouldPreserveExistingEntry && + existing.sourceEntryJson !== requestEntryJson; const priority = request.priority ?? builderBodyHydrationPriorityForRequest({ documentId: null }); @@ -1496,17 +1627,22 @@ async function enqueueBuilderBodyHydrations( sourceTable: request.sourceTable, sourceEntryJson: shouldPreserveExistingEntry ? existing!.sourceEntryJson - : JSON.stringify(request.entry), + : requestEntryJson, priority: Math.min(existing?.priority ?? priority, priority), - attempts: existing?.attempts ?? 0, - lastAttemptedAt: existing?.lastAttemptedAt ?? null, + attempts: sourceEntryChanged ? 0 : (existing?.attempts ?? 0), + lastAttemptedAt: sourceEntryChanged + ? null + : (existing?.lastAttemptedAt ?? null), lastError: null, + nextAttemptAt: sourceEntryChanged + ? null + : (existing?.nextAttemptAt ?? null), createdAt: existing?.createdAt ?? request.now, updatedAt: request.now, }); } const upsertedRows: ContentDatabaseBodyHydrationQueueRowDb[] = []; - for (const chunk of chunks(queueRows, bulkChunkSizeForColumnCount(15))) { + for (const chunk of chunks(queueRows, bulkChunkSizeForColumnCount(16))) { upsertedRows.push( ...(await tx .insert(schema.contentDatabaseBodyHydrationQueue) @@ -1522,7 +1658,10 @@ async function enqueueBuilderBodyHydrations( sourceTable: sql`excluded.source_table`, sourceEntryJson: sql`excluded.source_entry_json`, priority: sql`excluded.priority`, + attempts: sql`excluded.attempts`, + lastAttemptedAt: sql`excluded.last_attempted_at`, lastError: null, + nextAttemptAt: sql`excluded.next_attempt_at`, updatedAt: sql`excluded.updated_at`, }, }) @@ -1560,6 +1699,7 @@ export async function enqueueBuilderBodyHydrationForItems(args: { entry: BuilderCmsSourceEntry; bodyHydrationStatus: string | null; bodyHydrationVersion: string | null; + bodyHydrationRetryable: number | null; documentContent: string | null; } >(); @@ -1574,6 +1714,8 @@ export async function enqueueBuilderBodyHydrationForItems(args: { schema.contentDatabaseSourceRows.lastSourceUpdatedAt, bodyHydrationStatus: schema.contentDatabaseItems.bodyHydrationStatus, bodyHydrationVersion: schema.contentDatabaseItems.bodyHydrationVersion, + bodyHydrationRetryable: + schema.contentDatabaseItems.bodyHydrationRetryable, documentContent: schema.documents.content, }) .from(schema.contentDatabaseSourceRows) @@ -1605,6 +1747,7 @@ export async function enqueueBuilderBodyHydrationForItems(args: { entry, bodyHydrationStatus: row.bodyHydrationStatus, bodyHydrationVersion: row.bodyHydrationVersion, + bodyHydrationRetryable: row.bodyHydrationRetryable, documentContent: row.documentContent, }); } @@ -1620,14 +1763,19 @@ export async function enqueueBuilderBodyHydrationForItems(args: { persistedState?.bodyHydrationStatus ?? item.bodyHydration?.status; const bodyHydrationVersion = persistedState?.bodyHydrationVersion ?? item.bodyHydration?.version; + const bodyHydrationRetryable = + persistedState?.bodyHydrationRetryable ?? + (item.bodyHydration?.retryable === false ? 0 : null); const documentContent = persistedState?.documentContent ?? item.document.content; const expectedVersion = - bodyHydrationStatus === "unavailable" + bodyHydrationStatus === "unavailable" || + (bodyHydrationStatus === "error" && bodyHydrationRetryable === 0) ? builderBodyUnavailableVersion(persistedEntry) : builderBodyHydrationVersion(persistedEntry); if ( (bodyHydrationStatus === "unavailable" || + (bodyHydrationStatus === "error" && bodyHydrationRetryable === 0) || (bodyHydrationStatus === "hydrated" && !isEffectivelyEmptyDocumentContent(documentContent) && !builderBodyIsRawPlaceholderOnly(documentContent))) && @@ -1759,6 +1907,14 @@ async function processBuilderBodyHydrationJob( }, ) { const db = getDb(); + const reasonedOutcomesEnabled = await isFeatureFlagEnabled( + BUILDER_BODY_HYDRATION_REASONS_FLAG, + { + userEmail: row.ownerEmail, + userKey: row.ownerEmail, + orgId: row.orgId, + }, + ); const entry = parseHydrationEntry(row); if (!entry) throw new Error("Builder body hydration entry is missing."); const queuedBlocksHash = stringSourceValue( @@ -1822,17 +1978,26 @@ async function processBuilderBodyHydrationJob( "Builder body baseline migration requires a linked source row.", ); } - const liveEntry = await readBuilderEntryWithLiveBodyFromSourceRow({ + const liveRead = await readBuilderEntryWithLiveBodyFromSourceRow({ row: sourceRow, sourceTable: row.sourceTable, fallbackTitle: entry.title, + reasonedOutcomesEnabled, }); - if (!liveEntry) { + if (liveRead.state === "not_found") { + throw new BuilderBodyHydrationError( + "Builder no longer returns the source entry needed for body migration.", + "not_found", + liveRead.providerStatus, + true, + ); + } + if (liveRead.state === "empty_body" && !reasonedOutcomesEnabled) { throw new Error( "Builder body baseline migration could not read a fresh remote body; retry the refresh before reviewing or publishing.", ); } - entryWithBody = liveEntry; + entryWithBody = liveRead.entry; } const incomingBlocksHash = stringSourceValue( entryWithBody.sourceValues, @@ -1873,6 +2038,10 @@ async function processBuilderBodyHydrationJob( }; let nextContent = stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; + let emptyBodyRead: Extract< + BuilderLiveBodyReadResult, + { state: "empty_body" | "not_found" } + > | null = null; if (!nextContent.trim()) { const rebuiltBaseEntry = sourceRow ? builderEntryFromSourceRow({ @@ -1922,12 +2091,14 @@ async function processBuilderBodyHydrationJob( } } if (!nextContent.trim() && sourceRow) { - const liveEntry = await readBuilderEntryWithLiveBodyFromSourceRow({ + const liveRead = await readBuilderEntryWithLiveBodyFromSourceRow({ row: sourceRow, sourceTable: row.sourceTable, fallbackTitle: entry.title, + reasonedOutcomesEnabled, }); - if (liveEntry) { + if (liveRead.state === "body") { + const liveEntry = liveRead.entry; const liveValues = { ...sourceValues, ...liveEntry.sourceValues, @@ -1963,6 +2134,8 @@ async function processBuilderBodyHydrationJob( nextValues = liveValues; nextContent = liveContent; } + } else { + emptyBodyRead = liveRead; } } if (!nextContent.trim()) { @@ -1988,7 +2161,7 @@ async function processBuilderBodyHydrationJob( }) .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); }; - if (builderBodyHydrationAttemptIsTerminal(attempts)) { + if (reasonedOutcomesEnabled && emptyBodyRead?.state === "empty_body") { const [deleted] = await tx .delete(schema.contentDatabaseBodyHydrationQueue) .where(queueRowCas) @@ -1997,14 +2170,81 @@ async function processBuilderBodyHydrationJob( await markPendingIfReplaced(); return; } + await tx + .update(schema.contentDatabaseSourceRows) + .set({ + sourceValuesJson: JSON.stringify({ + ...sourceValues, + ...emptyBodyRead.entry.sourceValues, + }), + lastSyncedAt: now, + lastSourceUpdatedAt: emptyBodyRead.entry.updatedAt ?? now, + updatedAt: now, + }) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId), + eq( + schema.contentDatabaseSourceRows.databaseItemId, + row.databaseItemId, + ), + ), + ); await tx .update(schema.contentDatabaseItems) .set({ - bodyHydrationStatus: "unavailable", + bodyHydrationStatus: "hydrated", bodyHydrationAttemptedAt: now, bodyHydrationError: null, + bodyHydrationVersion: builderBodyHydrationVersion( + emptyBodyRead.entry, + ), + bodyHydrationReason: "empty_body", + bodyHydrationProviderStatus: emptyBodyRead.providerStatus, + bodyHydrationAttemptCount: attempts, + bodyHydrationRetryable: 0, + updatedAt: now, + }) + .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); + return; + } + if (builderBodyHydrationAttemptIsTerminal(attempts)) { + const [deleted] = await tx + .delete(schema.contentDatabaseBodyHydrationQueue) + .where(queueRowCas) + .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); + if (!deleted) { + await markPendingIfReplaced(); + return; + } + const reason = reasonedOutcomesEnabled + ? emptyBodyRead?.state === "not_found" + ? "not_found" + : "conversion_failed" + : null; + await tx + .update(schema.contentDatabaseItems) + .set({ + bodyHydrationStatus: reason ? "error" : "unavailable", + bodyHydrationAttemptedAt: now, + bodyHydrationError: + reason === "not_found" + ? "Builder no longer returns this source entry. Refresh the source or retry after restoring access." + : reason === "conversion_failed" + ? "Content could not construct a Builder body from the retained source record. Refresh the source to recover the authoritative body." + : null, bodyHydrationVersion: builderBodyUnavailableVersion(entryWithBody), + bodyHydrationReason: reason, + bodyHydrationProviderStatus: + reason && emptyBodyRead + ? emptyBodyRead.providerStatus + : reason + ? "local_source_record" + : null, + bodyHydrationAttemptCount: attempts, + bodyHydrationRetryable: + reason === "not_found" ? 1 : reason ? 0 : null, updatedAt: now, }) .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); @@ -2015,6 +2255,9 @@ async function processBuilderBodyHydrationJob( .set({ lastAttemptedAt: null, lastError: BUILDER_BODY_NOT_AVAILABLE_ERROR, + nextAttemptAt: reasonedOutcomesEnabled + ? builderBodyHydrationNextAttemptAt(attempts, now) + : null, updatedAt: now, }) .where(queueRowCas) @@ -2028,7 +2271,19 @@ async function processBuilderBodyHydrationJob( .set({ bodyHydrationStatus: "pending", bodyHydrationAttemptedAt: now, - bodyHydrationError: null, + bodyHydrationError: reasonedOutcomesEnabled + ? BUILDER_BODY_NOT_AVAILABLE_ERROR + : null, + bodyHydrationReason: + reasonedOutcomesEnabled && emptyBodyRead?.state === "not_found" + ? "not_found" + : null, + bodyHydrationProviderStatus: + reasonedOutcomesEnabled && emptyBodyRead + ? emptyBodyRead.providerStatus + : null, + bodyHydrationAttemptCount: attempts, + bodyHydrationRetryable: reasonedOutcomesEnabled ? 1 : null, updatedAt: now, }) .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); @@ -2211,6 +2466,12 @@ async function processBuilderBodyHydrationJob( bodyHydrationAttemptedAt: now, bodyHydrationError: null, bodyHydrationVersion: builderBodyHydrationVersion(entryWithBody), + bodyHydrationReason: null, + bodyHydrationProviderStatus: reasonedOutcomesEnabled + ? "http_200" + : null, + bodyHydrationAttemptCount: row.attempts, + bodyHydrationRetryable: reasonedOutcomesEnabled ? 0 : null, updatedAt: now, }) .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); @@ -2602,21 +2863,27 @@ export async function processBuilderBodyHydrationQueue(args: { .select() .from(schema.contentDatabaseBodyHydrationQueue) .where( - args.documentId - ? and( - eq( + and( + or( + isNull(schema.contentDatabaseBodyHydrationQueue.nextAttemptAt), + lte(schema.contentDatabaseBodyHydrationQueue.nextAttemptAt, now), + ), + args.documentId + ? and( + eq( + schema.contentDatabaseBodyHydrationQueue.sourceId, + args.sourceId, + ), + eq( + schema.contentDatabaseBodyHydrationQueue.documentId, + args.documentId, + ), + ) + : eq( schema.contentDatabaseBodyHydrationQueue.sourceId, args.sourceId, ), - eq( - schema.contentDatabaseBodyHydrationQueue.documentId, - args.documentId, - ), - ) - : eq( - schema.contentDatabaseBodyHydrationQueue.sourceId, - args.sourceId, - ), + ), ) .orderBy( asc(schema.contentDatabaseBodyHydrationQueue.priority), @@ -2626,7 +2893,11 @@ export async function processBuilderBodyHydrationQueue(args: { const jobs = await (args.preloadedJobs?.length && !args.documentId ? (() => { const preloadedJobs = sortBuilderBodyHydrationQueueForProcessing( - args.preloadedJobs!.filter((job) => job.sourceId === args.sourceId), + args.preloadedJobs!.filter( + (job) => + job.sourceId === args.sourceId && + (!job.nextAttemptAt || job.nextAttemptAt <= now), + ), ).slice(0, limit); return persistedJobs(limit + preloadedJobs.length).then((rows) => { const preloadedIds = new Set(preloadedJobs.map((job) => job.id)); @@ -2859,6 +3130,15 @@ export async function processBuilderBodyHydrationQueue(args: { } catch (error) { failed += 1; const message = error instanceof Error ? error.message : String(error); + const reasonedOutcomesEnabled = await isFeatureFlagEnabled( + BUILDER_BODY_HYDRATION_REASONS_FLAG, + { + userEmail: job.ownerEmail, + userKey: job.ownerEmail, + orgId: job.orgId, + }, + ); + const evidence = builderBodyHydrationFailureEvidence(error); const attempts = job.attempts; const queueRowCas = builderBodyHydrationQueueOwnershipFilter(job); const markPendingIfReplaced = async () => { @@ -2877,7 +3157,10 @@ export async function processBuilderBodyHydrationQueue(args: { }) .where(eq(schema.contentDatabaseItems.id, job.databaseItemId)); }; - if (builderBodyHydrationAttemptIsTerminal(attempts)) { + if ( + builderBodyHydrationAttemptIsTerminal(attempts) || + (reasonedOutcomesEnabled && !evidence.retryable) + ) { const [deleted] = await db .delete(schema.contentDatabaseBodyHydrationQueue) .where(queueRowCas) @@ -2892,6 +3175,21 @@ export async function processBuilderBodyHydrationQueue(args: { bodyHydrationStatus: "error", bodyHydrationAttemptedAt: attemptNow, bodyHydrationError: message, + bodyHydrationVersion: parseHydrationEntry(job) + ? builderBodyUnavailableVersion(parseHydrationEntry(job)!) + : null, + bodyHydrationReason: reasonedOutcomesEnabled + ? evidence.reason + : null, + bodyHydrationProviderStatus: reasonedOutcomesEnabled + ? evidence.providerStatus + : null, + bodyHydrationAttemptCount: attempts, + bodyHydrationRetryable: reasonedOutcomesEnabled + ? evidence.retryable + ? 1 + : 0 + : null, updatedAt: attemptNow, }) .where(eq(schema.contentDatabaseItems.id, job.databaseItemId)); @@ -2904,6 +3202,9 @@ export async function processBuilderBodyHydrationQueue(args: { lastAttemptedAt: null, lastError: message, priority: job.priority + 10, + nextAttemptAt: reasonedOutcomesEnabled + ? builderBodyHydrationNextAttemptAt(attempts, attemptNow) + : null, updatedAt: attemptNow, }) .where(queueRowCas) @@ -2915,9 +3216,21 @@ export async function processBuilderBodyHydrationQueue(args: { await db .update(schema.contentDatabaseItems) .set({ - bodyHydrationStatus: "error", + bodyHydrationStatus: reasonedOutcomesEnabled ? "pending" : "error", bodyHydrationAttemptedAt: attemptNow, bodyHydrationError: message, + bodyHydrationReason: reasonedOutcomesEnabled + ? evidence.reason + : null, + bodyHydrationProviderStatus: reasonedOutcomesEnabled + ? evidence.providerStatus + : null, + bodyHydrationAttemptCount: attempts, + bodyHydrationRetryable: reasonedOutcomesEnabled + ? evidence.retryable + ? 1 + : 0 + : null, updatedAt: attemptNow, }) .where(eq(schema.contentDatabaseItems.id, job.databaseItemId)); @@ -4713,6 +5026,7 @@ async function sourceBodyHydrationSummary(args: { const rows = await getDb() .select({ status: schema.contentDatabaseItems.bodyHydrationStatus, + retryable: schema.contentDatabaseItems.bodyHydrationRetryable, queueId: schema.contentDatabaseBodyHydrationQueue.id, }) .from(schema.contentDatabaseItems) @@ -4745,6 +5059,7 @@ async function sourceBodyHydrationSummary(args: { hydrated: 0, unavailable: 0, error: 0, + retryableErrors: 0, total: rows.length, }; for (const row of rows) { @@ -4752,8 +5067,10 @@ async function sourceBodyHydrationSummary(args: { summary.pending += 1; } else if (row.status === "hydrating") summary.hydrating += 1; else if (row.status === "unavailable") summary.unavailable! += 1; - else if (row.status === "error") summary.error += 1; - else summary.hydrated += 1; + else if (row.status === "error") { + summary.error += 1; + if (row.retryable !== 0) summary.retryableErrors! += 1; + } else summary.hydrated += 1; } return summary; } @@ -6413,6 +6730,10 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { attemptedAt: null, error: null, version: null, + reason: null, + providerStatus: null, + attemptCount: 0, + retryable: null, }, }; }), @@ -6456,6 +6777,17 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { attemptedAt: row.item.bodyHydrationAttemptedAt, error: row.item.bodyHydrationError, version: row.item.bodyHydrationVersion, + reason: + (row.item + .bodyHydrationReason as ContentDatabaseBodyHydration["reason"]) ?? + null, + providerStatus: + row.item.bodyHydrationProviderStatus ?? null, + attemptCount: row.item.bodyHydrationAttemptCount ?? 0, + retryable: + row.item.bodyHydrationRetryable === null + ? null + : row.item.bodyHydrationRetryable === 1, }, }, ]; @@ -6541,6 +6873,10 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { attemptedAt: null, error: null, version: null, + reason: null, + providerStatus: null, + attemptCount: 0, + retryable: null, }, }; }); diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 96881f9cb9..5572faac9c 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -274,6 +274,26 @@ export function serializeBodyHydration( attemptedAt: item.bodyHydrationAttemptedAt, error: item.bodyHydrationError, version: item.bodyHydrationVersion, + reason: + item.bodyHydrationReason === "empty_body" || + item.bodyHydrationReason === "not_found" || + item.bodyHydrationReason === "auth_failed" || + item.bodyHydrationReason === "access_denied" || + item.bodyHydrationReason === "transient_read_failure" || + item.bodyHydrationReason === "malformed_body" || + item.bodyHydrationReason === "unsupported_content" || + item.bodyHydrationReason === "conversion_failed" + ? item.bodyHydrationReason + : undefined, + providerStatus: item.bodyHydrationProviderStatus ?? undefined, + attemptCount: + item.bodyHydrationAttemptCount > 0 + ? item.bodyHydrationAttemptCount + : undefined, + retryable: + item.bodyHydrationRetryable === null + ? undefined + : item.bodyHydrationRetryable === 1, }; } diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index b0234e7b19..a4bade2136 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -41,6 +41,14 @@ const builderReadMock = vi.hoisted(() => ({ | ((args: { model: string; entryId: string }) => Promise | void) | null, })); +const hydrationReasonsFlagMock = vi.hoisted(() => ({ enabled: false })); + +vi.mock("@agent-native/core/feature-flags", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@agent-native/core/feature-flags") + >()), + isFeatureFlagEnabled: vi.fn(async () => hydrationReasonsFlagMock.enabled), +})); // Mock the Builder read client so resync runs "live" with deterministic entries // (no network). Real exports are preserved; only the two reads are overridden. @@ -85,7 +93,8 @@ vi.mock("./_builder-cms-read-client.js", async () => { model !== "collection-metadata-only" && model !== "collection-large-597" && model !== "collection-canonical-hash-repair" && - model !== "collection-same-version-conflict" + model !== "collection-same-version-conflict" && + model !== "collection-hydration-empty-terminal" ) { return null; } @@ -105,6 +114,21 @@ vi.mock("./_builder-cms-read-client.js", async () => { }, }; } + if (model === "collection-hydration-empty-terminal") { + return { + id: entryId, + model, + title: "Hydration empty terminal", + urlPath: "/blog/hydration-empty-terminal", + updatedAt: "2026-01-01T00:00:00.000Z", + sourceValues: { "data.title": "Hydration empty terminal" }, + rawEntry: { + id: entryId, + model, + data: { title: "Hydration empty terminal", blocks: [] }, + }, + }; + } const title = model === "collection-same-version-conflict" ? "Same version conflict" @@ -171,6 +195,16 @@ vi.mock("./_builder-cms-read-client.js", async () => { }; }, ), + readBuilderCmsContentEntryResult: vi.fn( + async (args: { model: string; entryId: string }) => { + const { readBuilderCmsContentEntry } = + await import("./_builder-cms-read-client.js"); + const entry = await readBuilderCmsContentEntry(args); + return entry + ? { state: "found", entry, providerStatus: "http_200" } + : { state: "not_found", entry: null, providerStatus: "http_404" }; + }, + ), readBuilderCmsContentEntries: vi.fn( async ({ model, @@ -713,6 +747,7 @@ afterEach(() => { builderReadMock.modelFieldsErrorFor = null; builderReadMock.singleEntryErrorFor = null; builderReadMock.beforeSingleEntryRead = null; + hydrationReasonsFlagMock.enabled = false; }); afterAll(() => { @@ -3202,7 +3237,7 @@ it("continues a 597-row snapshot past offset 500 without pruning or restarting", source, now: `2026-07-10T12:0${page}:00.000Z`, }); - for (let drain = 0; drain < 4; drain += 1) { + for (let drain = 0; drain < 7; drain += 1) { const queuedBodies = await db .select({ id: schema.contentDatabaseBodyHydrationQueue.id }) .from(schema.contentDatabaseBodyHydrationQueue) @@ -4885,7 +4920,8 @@ it("preserves local content and the source baseline when Builder returns a confl expect(after.queueError).toContain("inconsistent body variants"); }); -it("terminates an unbuildable empty Builder body job at the hydration cap", async () => { +it("hydrates a provider-confirmed empty Builder body with terminal evidence", async () => { + hydrationReasonsFlagMock.enabled = true; builderReadMock.mode = "full"; builderReadMock.calls = []; builderReadMock.singleEntryCalls = []; @@ -4991,39 +5027,16 @@ it("terminates an unbuildable empty Builder body job at the hydration cap", asyn await hydrateQueuedBodies({ sourceId, limit: 1, preloadBodies: true }); - const [retryable] = await db - .select({ - status: schema.contentDatabaseItems.bodyHydrationStatus, - attempts: schema.contentDatabaseBodyHydrationQueue.attempts, - lastAttemptedAt: schema.contentDatabaseBodyHydrationQueue.lastAttemptedAt, - }) - .from(schema.contentDatabaseItems) - .innerJoin( - schema.contentDatabaseBodyHydrationQueue, - eq( - schema.contentDatabaseBodyHydrationQueue.databaseItemId, - schema.contentDatabaseItems.id, - ), - ) - .where(eq(schema.contentDatabaseItems.documentId, documentId)); - - expect(retryable).toMatchObject({ - status: "pending", - attempts: 1, - lastAttemptedAt: null, - }); - - for (let attempt = 1; attempt < 5; attempt += 1) { - await hydrateQueuedBodies({ sourceId, limit: 1, preloadBodies: true }); - } - const [after] = await db .select({ content: schema.documents.content, status: schema.contentDatabaseItems.bodyHydrationStatus, error: schema.contentDatabaseItems.bodyHydrationError, + reason: schema.contentDatabaseItems.bodyHydrationReason, + providerStatus: schema.contentDatabaseItems.bodyHydrationProviderStatus, + attemptCount: schema.contentDatabaseItems.bodyHydrationAttemptCount, + retryable: schema.contentDatabaseItems.bodyHydrationRetryable, queued: schema.contentDatabaseBodyHydrationQueue.id, - attempts: schema.contentDatabaseBodyHydrationQueue.attempts, }) .from(schema.documents) .innerJoin( @@ -5040,10 +5053,13 @@ it("terminates an unbuildable empty Builder body job at the hydration cap", asyn .where(eq(schema.documents.id, documentId)); expect(after.content).toBe(""); - expect(after.status).toBe("unavailable"); + expect(after.status).toBe("hydrated"); expect(after.error).toBeNull(); + expect(after.reason).toBe("empty_body"); + expect(after.providerStatus).toBe("http_200"); + expect(after.attemptCount).toBe(1); + expect(after.retryable).toBe(0); expect(after.queued).toBeNull(); - expect(after.attempts).toBeNull(); }); it("re-enqueues hydrated Builder rows with empty document content on resync", async () => { diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 54369dcbea..cc09417084 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -764,6 +764,7 @@ function DocumentEditorBody({ ) { return; } + if (hydration.status === "error" && hydration.retryable === false) return; const promotionKey = `${hydrationContext.sourceId}:${documentId}:${hydration.status}:${hydration.version ?? ""}`; if (promotedBuilderBodyRef.current === promotionKey) return; promotedBuilderBodyRef.current = promotionKey; @@ -847,6 +848,10 @@ function DocumentEditorBody({ user: currentUser, }); const bodyHydrationPending = documentBodyHydrationIsPending(document); + const bodyHydrationError = + document.bodyHydration?.hydration?.status === "error" + ? document.bodyHydration.hydration + : null; const editorCanEdit = canEdit && !bodyHydrationPending && @@ -2264,6 +2269,18 @@ function DocumentEditorBody({ ); } + if (bodyHydrationError) { + return ( + + ); + } + if (showNewDocumentTypeChooser) { return (
{ document: documentWithHydration("error"), }), ).toBe(true); + expect( + previewBodyHydrationTerminalError({ + item, + document: { + ...documentWithHydration("error"), + bodyHydration: { + ...documentWithHydration("error").bodyHydration!, + hydration: { + status: "error", + attemptedAt: "2026-08-21T12:00:00.000Z", + error: "Builder denied access to this entry.", + version: "v2", + reason: "access_denied", + providerStatus: "http_403", + attemptCount: 1, + retryable: false, + }, + }, + }, + }), + ).toMatchObject({ + reason: "access_denied", + providerStatus: "http_403", + retryable: false, + }); }); it("keeps a non-empty draft recoverable when Builder hydrates a body over its empty baseline", () => { diff --git a/templates/content/app/components/editor/body-hydration.ts b/templates/content/app/components/editor/body-hydration.ts index 7f51f72736..15f9a879bb 100644 --- a/templates/content/app/components/editor/body-hydration.ts +++ b/templates/content/app/components/editor/body-hydration.ts @@ -136,15 +136,26 @@ export function previewBodyHydrationIsTerminalError(args: { | null | undefined; }) { - return ( - builderBodyHydrationIsTerminalError( - args.document?.bodyHydration?.hydration, - ) || - builderBodyHydrationIsTerminalError( - args.item.bodyHydration ?? - args.item.document.databaseMembership?.bodyHydration, - ) - ); + return Boolean(previewBodyHydrationTerminalError(args)); +} + +export function previewBodyHydrationTerminalError(args: { + item: Pick; + document: + | Pick + | null + | undefined; +}) { + const documentHydration = args.document?.bodyHydration?.hydration; + if (builderBodyHydrationIsTerminalError(documentHydration)) { + return documentHydration; + } + const itemHydration = + args.item.bodyHydration ?? + args.item.document.databaseMembership?.bodyHydration; + return builderBodyHydrationIsTerminalError(itemHydration) + ? itemHydration + : null; } export function isEffectivelyEmptyDocumentContent( diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index f5e9228ffb..5d1109834b 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -224,7 +224,7 @@ import { databaseItemBodyHydrationIsPending, isEffectivelyEmptyDocumentContent, previewBodyHydrationIsPending, - previewBodyHydrationIsTerminalError, + previewBodyHydrationTerminalError, previewDraftConflictsWithHydratedBody, shouldIgnorePreviewEmptyNormalization, } from "../body-hydration"; @@ -4421,7 +4421,7 @@ function DatabaseItemPreview({ item, document, }); - const bodyHydrationError = previewBodyHydrationIsTerminalError({ + const bodyHydrationError = previewBodyHydrationTerminalError({ item, document, }); @@ -5324,7 +5324,10 @@ function DatabaseItemPreview({ {bodyHydrationError ? ( ) : null} {editor} @@ -9876,7 +9879,10 @@ function BuilderBodyHydrationCard({ const summary = source.bodyHydration; if (!summary || summary.total === 0) return null; const activeCount = summary.pending + summary.hydrating; - const needsWork = activeCount > 0 || summary.error > 0; + const hasErrors = summary.error > 0; + const needsWork = activeCount > 0 || hasErrors; + const canResume = + activeCount > 0 || (summary.retryableErrors ?? summary.error) > 0; const hydratedLabel = dbText("builderBodiesHydrated", { hydrated: summary.hydrated, total: summary.total, @@ -9903,7 +9909,7 @@ function BuilderBodyHydrationCard({ {hydratedLabel}
- {needsWork ? ( + {canResume ? ( ) : null} diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index b448f4e906..e4bf0cb985 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -277,6 +277,12 @@ export const contentDatabaseItems = table( bodyHydrationAttemptedAt: text("body_hydration_attempted_at"), bodyHydrationError: text("body_hydration_error"), bodyHydrationVersion: text("body_hydration_version"), + bodyHydrationReason: text("body_hydration_reason"), + bodyHydrationProviderStatus: text("body_hydration_provider_status"), + bodyHydrationAttemptCount: integer("body_hydration_attempt_count") + .notNull() + .default(0), + bodyHydrationRetryable: integer("body_hydration_retryable"), createdAt: text("created_at").notNull().default(now()), updatedAt: text("updated_at").notNull().default(now()), }, @@ -331,6 +337,7 @@ export const contentDatabaseBodyHydrationQueue = table( attempts: integer("attempts").notNull().default(0), lastAttemptedAt: text("last_attempted_at"), lastError: text("last_error"), + nextAttemptAt: text("next_attempt_at"), createdAt: text("created_at").notNull().default(now()), updatedAt: text("updated_at").notNull().default(now()), }, diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 69102524b7..aee81e1b09 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -1041,6 +1041,15 @@ export const runContentMigrations = runMigrations( CREATE INDEX IF NOT EXISTS content_database_row_mutation_receipts_document_idx ON content_database_row_mutation_receipts (document_id)`, }, + { + version: 85, + name: "builder-body-hydration-terminal-evidence", + sql: `ALTER TABLE content_database_items ADD COLUMN IF NOT EXISTS body_hydration_reason TEXT; + ALTER TABLE content_database_items ADD COLUMN IF NOT EXISTS body_hydration_provider_status TEXT; + ALTER TABLE content_database_items ADD COLUMN IF NOT EXISTS body_hydration_attempt_count INTEGER NOT NULL DEFAULT 0; + ALTER TABLE content_database_items ADD COLUMN IF NOT EXISTS body_hydration_retryable INTEGER; + ALTER TABLE content_database_body_hydration_queue ADD COLUMN IF NOT EXISTS next_attempt_at TEXT`, + }, ], { table: "content_migrations" }, ); diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 8a920ffa7c..4471fcd3d8 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -448,7 +448,21 @@ export interface ContentDatabaseBodyHydration { attemptedAt: string | null; error: string | null; version: string | null; -} + reason?: ContentDatabaseBodyHydrationReason | null; + providerStatus?: string | null; + attemptCount?: number; + retryable?: boolean | null; +} + +export type ContentDatabaseBodyHydrationReason = + | "empty_body" + | "not_found" + | "auth_failed" + | "access_denied" + | "transient_read_failure" + | "malformed_body" + | "unsupported_content" + | "conversion_failed"; export interface ContentDatabaseBodyHydrationSummary { pending: number; @@ -456,6 +470,7 @@ export interface ContentDatabaseBodyHydrationSummary { hydrated: number; unavailable?: number; error: number; + retryableErrors?: number; total: number; } diff --git a/templates/content/shared/feature-flags.ts b/templates/content/shared/feature-flags.ts index c545b5e844..7a8c7083e4 100644 --- a/templates/content/shared/feature-flags.ts +++ b/templates/content/shared/feature-flags.ts @@ -10,6 +10,14 @@ export const A2A_RECEIVER_OWNERSHIP_FLAG = defineFeatureFlag({ "Prefer Content's declared local capabilities when another app delegates an objective to Content.", }); +export const BUILDER_BODY_HYDRATION_REASONS_FLAG = defineFeatureFlag({ + key: "content.builder-body-hydration-reasons", + displayName: "Builder body hydration reasons", + description: + "Classify Builder body reads, accept confirmed empty bodies, and retain actionable terminal evidence.", +}); + export const CONTENT_FEATURE_FLAGS = defineFeatureFlags([ A2A_RECEIVER_OWNERSHIP_FLAG, + BUILDER_BODY_HYDRATION_REASONS_FLAG, ]);