diff --git a/frontend/lib/domain/campaign.mjs b/frontend/lib/domain/campaign.mjs index 557b07f8..1802f818 100644 --- a/frontend/lib/domain/campaign.mjs +++ b/frontend/lib/domain/campaign.mjs @@ -13,6 +13,56 @@ function text(value, fallback = "") { return normalized || fallback; } +function safeList(value, maxItems = 20, maxLength = 500) { + return Array.from(new Set((Array.isArray(value) ? value : []) + .map((item) => text(item).slice(0, maxLength)) + .filter(Boolean))) + .slice(0, maxItems); +} + +function safeProviderError(value = null) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const code = text(value.code).slice(0, 160); + const message = text(value.message).slice(0, 500); + const recoveryAction = text(value.recoveryAction).slice(0, 120); + const correlationId = text(value.correlationId).slice(0, 240); + const provider = text(value.provider).slice(0, 120); + const model = text(value.model).slice(0, 240); + const httpStatus = Number(value.httpStatus); + if (!code && !message && !recoveryAction && !correlationId) return null; + return portableClone({ + code, + message, + retryable: value.retryable === true, + recoveryAction, + correlationId, + provider, + model, + httpStatus: Number.isInteger(httpStatus) ? httpStatus : null, + }); +} + +function cleanChannelState(state = {}, fallback = {}) { + const source = state && typeof state === "object" && !Array.isArray(state) ? state : {}; + const prior = fallback && typeof fallback === "object" && !Array.isArray(fallback) ? fallback : {}; + const status = text(source.status || prior.status, "generated"); + const qualityStatus = text(source.qualityStatus || prior.qualityStatus); + const retryCount = Number(source.retryCount ?? prior.retryCount ?? 0); + return portableClone({ + status, + qualityStatus, + edited: Boolean(source.edited ?? prior.edited), + approved: Boolean(source.approved ?? prior.approved), + generationRunId: text(source.generationRunId || prior.generationRunId), + issues: safeList(source.issues ?? prior.issues), + issueCodes: safeList(source.issueCodes ?? prior.issueCodes, 20, 160), + retryCount: Number.isFinite(retryCount) ? Math.max(0, Math.floor(retryCount)) : 0, + failureClass: text(source.failureClass || prior.failureClass).slice(0, 160), + providerError: safeProviderError(source.providerError || prior.providerError), + qualityRiskAccepted: Boolean(source.qualityRiskAccepted ?? prior.qualityRiskAccepted), + }); +} + function canonicalChannel(value) { const channel = text(value).toLowerCase(); if (["releasenotes", "release-notes", "release_notes"].includes(channel)) return "release_notes"; @@ -162,17 +212,20 @@ function createDraft({ if (edited) pushUniqueRevision(history, generatedRevision); + const recoveryState = cleanChannelState(draftState || {}, existingDraft?.recoveryState || {}); return createDomainRecord("ChannelDraft", { draftId: `draft-${fnv1a64(`${campaignId}:${channel}`)}`, campaignId, channel, qualityState: text(qualityState, "unknown"), + qualityStatus: recoveryState.qualityStatus, generated: generatedRevision, current: currentRevision, history, edited, approved: Boolean(draftState?.approved ?? existingDraft?.approved), generationRunId: text(draftState?.generationRunId || generationRunId || existingDraft?.generationRunId), + recoveryState, updatedAt, }); } @@ -203,12 +256,10 @@ function generationRunFrom(input) { function cleanChannelStates(value = {}) { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - return Object.fromEntries(Object.entries(value).map(([channel, state]) => [canonicalChannel(channel), { - status: text(state?.status, "generated"), - edited: Boolean(state?.edited), - approved: Boolean(state?.approved), - generationRunId: text(state?.generationRunId), - }])); + return Object.fromEntries(Object.entries(value).map(([channel, state]) => [ + canonicalChannel(channel), + cleanChannelState(state), + ])); } function cleanEditorState(value = {}, fallback = {}) { @@ -271,15 +322,26 @@ export function generatedPostsFromCampaign(campaign) { export function channelStatesFromCampaign(campaign) { const parsed = parseDomainRecord(campaign, "Campaign"); - return Object.fromEntries(Object.entries(parsed.drafts || {}).map(([channel, draft]) => { + const persisted = cleanChannelStates(parsed.channelStates || {}); + const channels = Array.from(new Set([ + ...(Array.isArray(parsed.channels) ? parsed.channels : []), + ...Object.keys(persisted), + ...Object.keys(parsed.drafts || {}), + ])); + return Object.fromEntries(channels.map((channel) => { + const draft = parsed.drafts?.[channel] || null; + if (!draft) return [channel, cleanChannelState(persisted[channel] || {})]; const generated = text(draft?.generated?.content, draft?.current?.content || ""); const current = text(draft?.current?.content); - return [channel, { - status: text(draft?.qualityState, "generated"), + return [channel, cleanChannelState({ + ...(persisted[channel] || {}), + ...(draft?.recoveryState || {}), + status: text(draft?.qualityState, draft?.recoveryState?.status || persisted[channel]?.status || "generated"), + qualityStatus: text(draft?.qualityStatus || draft?.recoveryState?.qualityStatus || persisted[channel]?.qualityStatus), edited: current !== generated, approved: Boolean(draft?.approved), - generationRunId: text(draft?.generationRunId), - }]; + generationRunId: text(draft?.generationRunId || persisted[channel]?.generationRunId), + })]; })); } @@ -300,23 +362,27 @@ export function createCampaignAggregate(input = {}) { const activeChannels = channels.length ? channels : [DEFAULT_CHANNEL]; const authoritativePosts = input.posts || {}; const generatedPosts = input.generatedPosts || input.result?.posts || {}; - const statuses = input.result?.generation_status || input.generationStatus || {}; + const statuses = cleanChannelStates(input.result?.generation_status || input.generationStatus || {}); const draftStates = cleanChannelStates(input.channelStates || {}); + const campaignChannelStates = {}; const drafts = {}; for (const channel of activeChannels) { const currentContent = text(authoritativePosts[channel]); const generatedContent = text(generatedPosts[channel], currentContent); + const existingDraft = input.existingDrafts?.[channel] || null; + const draftState = cleanChannelState(draftStates[channel] || statuses[channel] || {}, existingDraft?.recoveryState || {}); + campaignChannelStates[channel] = draftState; if (!currentContent && !generatedContent) continue; drafts[channel] = createDraft({ campaignId, channel, currentContent, generatedContent, - qualityState: draftStates[channel]?.status || statuses[channel]?.status || input.existingDrafts?.[channel]?.qualityState, + qualityState: draftState.status || existingDraft?.qualityState, updatedAt, - existingDraft: input.existingDrafts?.[channel] || null, - draftState: draftStates[channel] || null, + existingDraft, + draftState, generationRunId: generationRun?.generationRunId || "", }); } @@ -341,8 +407,9 @@ export function createCampaignAggregate(input = {}) { projectId: input.projectId || null, title, status: text(input.status, "draft"), - channels: Object.keys(drafts), + channels: activeChannels, drafts, + channelStates: cleanChannelStates(campaignChannelStates), sourceSnapshot, generationRun, generationResult, @@ -515,7 +582,7 @@ export function campaignToEditorState(input) { generation_status: { ...(campaign.generationResult?.generation_status || {}), - ...Object.fromEntries(Object.entries(channelStates).map(([channel, state]) => [channel, { status: state.status }])), + ...Object.fromEntries(Object.entries(channelStates).map(([channel, state]) => [channel, portableClone(state)])), }, package: campaign.generationResult?.package ? { ...campaign.generationResult.package, posts: portableClone(campaign.generationResult?.structuredPosts || {}) } diff --git a/frontend/lib/export/campaignExport.mjs b/frontend/lib/export/campaignExport.mjs index af36661d..511f29d5 100644 --- a/frontend/lib/export/campaignExport.mjs +++ b/frontend/lib/export/campaignExport.mjs @@ -1,4 +1,4 @@ -import { createDomainRecord, stableStringify } from "../domain/contracts.mjs"; +import { createDomainRecord, portableClone, stableStringify } from "../domain/contracts.mjs"; import { currentPostsFromCampaign } from "../domain/campaign.mjs"; import { migrateCanonicalCampaign } from "../domain/campaignCompatibility.mjs"; @@ -65,13 +65,22 @@ function packageContextMarkdown(pkg = {}) { function exportMetadata(campaign) { const qualityStates = Object.fromEntries( - campaign.channels.map((channel) => [channel, campaign.drafts[channel]?.qualityState || "unknown"]), + campaign.channels.map((channel) => [channel, + campaign.drafts[channel]?.qualityState + || campaign.channelStates?.[channel]?.qualityStatus + || campaign.channelStates?.[channel]?.status + || "unknown", + ]), ); const approvalStates = Object.fromEntries( - campaign.channels.map((channel) => [channel, Boolean(campaign.drafts[channel]?.approved)]), + campaign.channels.map((channel) => [channel, Boolean( + campaign.drafts[channel]?.approved ?? campaign.channelStates?.[channel]?.approved, + )]), ); const editedStates = Object.fromEntries( - campaign.channels.map((channel) => [channel, Boolean(campaign.drafts[channel]?.edited)]), + campaign.channels.map((channel) => [channel, Boolean( + campaign.drafts[channel]?.edited ?? campaign.channelStates?.[channel]?.edited, + )]), ); return { campaignId: campaign.campaignId, @@ -96,25 +105,27 @@ function exportMetadata(campaign) { export function projectCampaignExport(input) { const campaign = migrateCanonicalCampaign(input); const currentDrafts = Object.fromEntries( - campaign.channels.map((channel) => { - const draft = campaign.drafts[channel]; - return [channel, { - draftId: draft.draftId, - channel, - content: draft.current.content, - origin: draft.current.origin, - generatedContent: draft.generated?.content || draft.current.content, - edited: Boolean(draft.edited), - approved: Boolean(draft.approved), - qualityState: draft.qualityState, - generationRunId: draft.generationRunId || null, - updatedAt: draft.updatedAt, - structuredDraft: structuredPostForChannel(campaign.generationResult?.structuredPosts, channel), - structuredDraftOrigin: structuredPostForChannel(campaign.generationResult?.structuredPosts, channel) - ? "generation_snapshot" - : null, - }]; - }), + campaign.channels + .filter((channel) => Boolean(campaign.drafts[channel])) + .map((channel) => { + const draft = campaign.drafts[channel]; + return [channel, { + draftId: draft.draftId, + channel, + content: draft.current.content, + origin: draft.current.origin, + generatedContent: draft.generated?.content || draft.current.content, + edited: Boolean(draft.edited), + approved: Boolean(draft.approved), + qualityState: draft.qualityState, + generationRunId: draft.generationRunId || null, + updatedAt: draft.updatedAt, + structuredDraft: structuredPostForChannel(campaign.generationResult?.structuredPosts, channel), + structuredDraftOrigin: structuredPostForChannel(campaign.generationResult?.structuredPosts, channel) + ? "generation_snapshot" + : null, + }]; + }), ); const history = Object.fromEntries( campaign.channels @@ -141,6 +152,7 @@ export function projectCampaignExport(input) { title: campaign.title, status: campaign.status, channels: campaign.channels, + channelStates: portableClone(campaign.channelStates || {}), sourceSnapshot: campaign.sourceSnapshot, generationRun: campaign.generationRun, editorState: campaign.editorState, @@ -170,7 +182,7 @@ export function projectCampaignMarkdown(input) { content += "## Current channel drafts\n\n"; for (const channel of campaign.channels) { content += `### ${label(channel)}\n\n`; - content += `${posts[channel]}\n\n`; + content += `${posts[channel] || `[No current ${label(channel)} draft]`}\n\n`; content += `*State: ${metadata.approvalStates[channel] ? "approved" : metadata.editedStates[channel] ? "edited" : metadata.qualityStates[channel]}*\n\n`; } diff --git a/frontend/tests/campaignPersistenceVersioning.test.mjs b/frontend/tests/campaignPersistenceVersioning.test.mjs index a06f2650..8a3283c6 100644 --- a/frontend/tests/campaignPersistenceVersioning.test.mjs +++ b/frontend/tests/campaignPersistenceVersioning.test.mjs @@ -207,3 +207,95 @@ test("browser quota errors propagate so the UI can offer export recovery", async }); await assert.rejects(() => app.createCampaign(campaignInput()), /Quota exceeded/); }); + + +test("save and reopen preserve failed destination recovery metadata even without a draft body", async () => { + const app = application(); + const input = campaignInput({ + channels: ["linkedin", "x"], + posts: { + linkedin: "Successful LinkedIn draft.", + x: "", + }, + generatedPosts: { + linkedin: "Successful LinkedIn draft.", + x: "", + }, + channelStates: { + linkedin: { + status: "generated", + qualityStatus: "complete", + edited: false, + approved: false, + generationRunId: "run-recovery", + }, + x: { + status: "failed", + qualityStatus: "failed", + edited: false, + approved: false, + generationRunId: "run-recovery", + issues: ["The provider could not complete this destination."], + issueCodes: ["provider_rate_limited"], + retryCount: 2, + failureClass: "provider_rate_limited", + providerError: { + code: "provider_rate_limited", + message: "The provider is rate limiting requests.", + retryable: false, + recoveryAction: "wait_then_retry", + correlationId: "provider-safe-reference", + provider: "gemini", + model: "gemini-test", + httpStatus: 429, + rawResponse: "must-not-persist", + apiKey: "must-not-persist", + }, + }, + }, + result: { + ...campaignInput().result, + generation_status: { + linkedin: { status: "generated", qualityStatus: "complete" }, + x: { + status: "failed", + qualityStatus: "failed", + issues: ["The provider could not complete this destination."], + issueCodes: ["provider_rate_limited"], + retryCount: 2, + failureClass: "provider_rate_limited", + }, + }, + posts: { + linkedin: "Successful LinkedIn draft.", + x: "", + }, + }, + generationRun: { + ...campaignInput().generationRun, + generationRunId: "run-recovery", + }, + }); + + const saved = await app.createCampaign(input); + assert.equal(saved.drafts.x, undefined, "failed empty destination must not fabricate a content draft"); + assert.equal(saved.channels.includes("x"), true); + assert.equal(saved.channelStates.x.status, "failed"); + assert.equal(saved.channelStates.x.qualityStatus, "failed"); + + const persistedText = JSON.stringify(saved); + assert.doesNotMatch(persistedText, /rawResponse|must-not-persist|apiKey/); + + const reopened = app.openCampaign(await app.getCampaign(saved.campaignId)); + assert.equal(reopened.posts.linkedin, "Successful LinkedIn draft."); + assert.equal(reopened.posts.x, undefined, "failed destination without content must reopen without a fabricated post"); + assert.equal(reopened.channelStates.x.status, "failed"); + assert.equal(reopened.channelStates.x.qualityStatus, "failed"); + assert.deepEqual(reopened.channelStates.x.issueCodes, ["provider_rate_limited"]); + assert.equal(reopened.channelStates.x.retryCount, 2); + assert.equal(reopened.channelStates.x.failureClass, "provider_rate_limited"); + assert.equal(reopened.channelStates.x.providerError.code, "provider_rate_limited"); + assert.equal(reopened.channelStates.x.providerError.recoveryAction, "wait_then_retry"); + assert.equal(reopened.channelStates.x.providerError.correlationId, "provider-safe-reference"); + assert.equal(reopened.result.generation_status.x.providerError.code, "provider_rate_limited"); +});