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
101 changes: 84 additions & 17 deletions frontend/lib/domain/campaign.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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),
})];
}));
}

Expand All @@ -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 || "",
});
}
Expand All @@ -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,
Expand Down Expand Up @@ -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 || {}) }
Expand Down
60 changes: 36 additions & 24 deletions frontend/lib/export/campaignExport.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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`;
}

Expand Down
92 changes: 92 additions & 0 deletions frontend/tests/campaignPersistenceVersioning.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading