From 47b4fd5d9bdd2a5e0ed8965e245be809ce018745 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:00:29 -0700 Subject: [PATCH 1/7] Persist safe destination recovery state in campaigns --- frontend/lib/domain/campaign.mjs | 86 ++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/frontend/lib/domain/campaign.mjs b/frontend/lib/domain/campaign.mjs index 557b07f8..f6ddb2ce 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 = {}) { @@ -274,12 +325,14 @@ export function channelStatesFromCampaign(campaign) { return Object.fromEntries(Object.entries(parsed.drafts || {}).map(([channel, draft]) => { 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({ + ...(draft?.recoveryState || {}), + status: text(draft?.qualityState, draft?.recoveryState?.status || "generated"), + qualityStatus: text(draft?.qualityStatus || draft?.recoveryState?.qualityStatus), edited: current !== generated, approved: Boolean(draft?.approved), generationRunId: text(draft?.generationRunId), - }]; + })]; })); } @@ -300,23 +353,26 @@ 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 drafts = {}; for (const channel of activeChannels) { const currentContent = text(authoritativePosts[channel]); const generatedContent = text(generatedPosts[channel], currentContent); - if (!currentContent && !generatedContent) continue; + const existingDraft = input.existingDrafts?.[channel] || null; + const draftState = cleanChannelState(draftStates[channel] || statuses[channel] || {}, existingDraft?.recoveryState || {}); + const hasLifecycleState = Boolean(draftStates[channel] || statuses[channel] || existingDraft); + if (!currentContent && !generatedContent && !hasLifecycleState) 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 || "", }); } @@ -515,7 +571,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 || {}) } From 85f812ce13487e61608c4ae528522e9a7f4f9143 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:01:04 -0700 Subject: [PATCH 2/7] Test persisted destination recovery state --- .../campaignPersistenceVersioning.test.mjs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/frontend/tests/campaignPersistenceVersioning.test.mjs b/frontend/tests/campaignPersistenceVersioning.test.mjs index a06f2650..f4e8d3a8 100644 --- a/frontend/tests/campaignPersistenceVersioning.test.mjs +++ b/frontend/tests/campaignPersistenceVersioning.test.mjs @@ -207,3 +207,94 @@ 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.ok(saved.drafts.x, "failed destination should remain represented canonically"); + assert.equal(saved.drafts.x.current.content, ""); + assert.equal(saved.drafts.x.qualityState, "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, ""); + 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"); +}); From e47fb5ef84bf566ee0f0b34ce70a1f7641fda2c5 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:03:19 -0700 Subject: [PATCH 3/7] Represent failed destinations outside content drafts --- frontend/lib/domain/campaign.mjs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/frontend/lib/domain/campaign.mjs b/frontend/lib/domain/campaign.mjs index f6ddb2ce..1802f818 100644 --- a/frontend/lib/domain/campaign.mjs +++ b/frontend/lib/domain/campaign.mjs @@ -322,16 +322,25 @@ 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, cleanChannelState({ + ...(persisted[channel] || {}), ...(draft?.recoveryState || {}), - status: text(draft?.qualityState, draft?.recoveryState?.status || "generated"), - qualityStatus: text(draft?.qualityStatus || draft?.recoveryState?.qualityStatus), + 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), })]; })); } @@ -355,6 +364,7 @@ export function createCampaignAggregate(input = {}) { const generatedPosts = input.generatedPosts || input.result?.posts || {}; const statuses = cleanChannelStates(input.result?.generation_status || input.generationStatus || {}); const draftStates = cleanChannelStates(input.channelStates || {}); + const campaignChannelStates = {}; const drafts = {}; for (const channel of activeChannels) { @@ -362,8 +372,8 @@ export function createCampaignAggregate(input = {}) { const generatedContent = text(generatedPosts[channel], currentContent); const existingDraft = input.existingDrafts?.[channel] || null; const draftState = cleanChannelState(draftStates[channel] || statuses[channel] || {}, existingDraft?.recoveryState || {}); - const hasLifecycleState = Boolean(draftStates[channel] || statuses[channel] || existingDraft); - if (!currentContent && !generatedContent && !hasLifecycleState) continue; + campaignChannelStates[channel] = draftState; + if (!currentContent && !generatedContent) continue; drafts[channel] = createDraft({ campaignId, channel, @@ -397,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, From e52687929d63956d0d2537ba807623df09de8a09 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:03:33 -0700 Subject: [PATCH 4/7] Align recovery persistence test with campaign lifecycle state --- frontend/tests/campaignPersistenceVersioning.test.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/tests/campaignPersistenceVersioning.test.mjs b/frontend/tests/campaignPersistenceVersioning.test.mjs index f4e8d3a8..e3b6db1b 100644 --- a/frontend/tests/campaignPersistenceVersioning.test.mjs +++ b/frontend/tests/campaignPersistenceVersioning.test.mjs @@ -278,9 +278,10 @@ test("save and reopen preserve failed destination recovery metadata even without }); const saved = await app.createCampaign(input); - assert.ok(saved.drafts.x, "failed destination should remain represented canonically"); - assert.equal(saved.drafts.x.current.content, ""); - assert.equal(saved.drafts.x.qualityState, "failed"); + 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/); From f925e620437986fb8e425bdf1b9e6df44b063a9a Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:04:40 -0700 Subject: [PATCH 5/7] Assert failed destination reopens without fabricated content --- frontend/tests/campaignPersistenceVersioning.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tests/campaignPersistenceVersioning.test.mjs b/frontend/tests/campaignPersistenceVersioning.test.mjs index e3b6db1b..8a3283c6 100644 --- a/frontend/tests/campaignPersistenceVersioning.test.mjs +++ b/frontend/tests/campaignPersistenceVersioning.test.mjs @@ -288,7 +288,7 @@ test("save and reopen preserve failed destination recovery metadata even without const reopened = app.openCampaign(await app.getCampaign(saved.campaignId)); assert.equal(reopened.posts.linkedin, "Successful LinkedIn draft."); - assert.equal(reopened.posts.x, ""); + 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"]); From f443c1dcb053d888ccb02e251369a83f72b8a797 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:07:21 -0700 Subject: [PATCH 6/7] Separate destination lifecycle state from export drafts --- frontend/lib/export/campaignExport.mjs | 58 ++++++++++++++++---------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/frontend/lib/export/campaignExport.mjs b/frontend/lib/export/campaignExport.mjs index af36661d..7e2a05aa 100644 --- a/frontend/lib/export/campaignExport.mjs +++ b/frontend/lib/export/campaignExport.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`; } From 6281a0d74d872a2f8df0279bd76a6f1305c5646a Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:08:37 -0700 Subject: [PATCH 7/7] Import portable clone for lifecycle export state --- frontend/lib/export/campaignExport.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/lib/export/campaignExport.mjs b/frontend/lib/export/campaignExport.mjs index 7e2a05aa..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";