diff --git a/frontend/app/campaign-freshness.css b/frontend/app/campaign-freshness.css new file mode 100644 index 0000000..7feb93d --- /dev/null +++ b/frontend/app/campaign-freshness.css @@ -0,0 +1,99 @@ +.app-shell .review-workspace.has-stale-campaign .review-tabs { + grid-row: 2 / span 8; +} + +.app-shell .campaign-stale-banner { + grid-column: 1 / -1; + grid-row: 1; + padding: 1rem 1.1rem; + border: 0.0625rem solid #e2d2ad; + border-radius: var(--app-radius); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.55rem 1.25rem; + background: var(--app-warning-soft); + color: var(--app-ink); +} + +.app-shell .campaign-stale-banner__copy { + min-width: 0; +} + +.app-shell .campaign-stale-banner__label { + display: block; + margin-bottom: 0.25rem; + color: var(--app-warning); + font-size: 0.6875rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.app-shell .campaign-stale-banner strong { + display: block; + font-size: 0.9375rem; + line-height: 1.4; +} + +.app-shell .campaign-stale-banner p, +.app-shell .campaign-stale-banner small { + grid-column: 1; + margin: 0; + color: var(--app-muted); + font-size: 0.8125rem; + line-height: 1.55; +} + +.app-shell .campaign-stale-banner small { + color: var(--app-warning); + font-weight: 650; +} + +.app-shell .campaign-stale-banner button { + grid-column: 2; + grid-row: 1 / span 3; + min-height: 2.5rem; + padding: 0.6rem 0.85rem; + border: 0.0625rem solid #cfbb8d; + border-radius: 0.45rem; + background: #fffdf7; + color: var(--app-ink); + font-size: 0.75rem; + font-weight: 750; + cursor: pointer; +} + +.app-shell .campaign-stale-banner button:hover { + border-color: var(--app-warning); + background: #fff; +} + +.app-shell .connection-badge--stale { + border-color: #e2d2ad; + background: var(--app-warning-soft); + color: var(--app-warning); +} + +.app-shell .export-row button:disabled, +.app-shell .review-actions button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +@media (max-width: 48rem) { + .app-shell .campaign-stale-banner { + grid-template-columns: minmax(0, 1fr); + } + + .app-shell .campaign-stale-banner p, + .app-shell .campaign-stale-banner small, + .app-shell .campaign-stale-banner button { + grid-column: 1; + } + + .app-shell .campaign-stale-banner button { + grid-row: auto; + justify-self: start; + } +} diff --git a/frontend/app/layout.js b/frontend/app/layout.js index 2abce53..824ec30 100644 --- a/frontend/app/layout.js +++ b/frontend/app/layout.js @@ -6,6 +6,7 @@ import "../app/professional-polish.css"; import "../app/app-workspace.css"; import "../app/ui-containment.css"; import "../app/studio-product.css"; +import "../app/campaign-freshness.css"; import SessionBridge from "../components/SessionBridge"; const siteUrl = "https://signal-flow-studio.vercel.app"; diff --git a/frontend/app/page.js b/frontend/app/page.js index b982124..b83ee62 100644 --- a/frontend/app/page.js +++ b/frontend/app/page.js @@ -12,6 +12,13 @@ import { evaluateProviderReadiness, pickRecommendedProvider, } from "../lib/studio/providerReadiness.mjs"; +import { + createGenerationRun, + createGenerationSourceSnapshot, + getCampaignFreshness, + getGenerationSourceChanges, + restoreGenerationRun, +} from "../lib/studio/campaignFreshness.mjs"; const LEGACY_ACCESS_TOKEN_KEY = "signalflow_owner_token"; const LIBRARY_KEY = "signalflow_recovery_library"; @@ -539,6 +546,7 @@ export default function Home() { const [files, setFiles] = useState([]); const [documentText, setDocumentText] = useState([]); const [result, setResult] = useState(null); + const [generationRun, setGenerationRun] = useState(null); const [posts, setPosts] = useState({}); const [activeChannel, setActiveChannel] = useState("linkedin"); const [busy, setBusy] = useState(false); @@ -563,8 +571,28 @@ export default function Home() { const activeMeta = channelMeta(activeChannel); const currentPost = posts[activeChannel] || ""; const currentConnection = connections[activeChannel] || null; + const currentSourceSnapshot = useMemo( + () => + createGenerationSourceSnapshot( + { form, channels, files, documentText }, + { createdAt: null }, + ), + [form, channels, files, documentText], + ); + const campaignFreshness = getCampaignFreshness({ + hasResult: Boolean(result), + currentSourceFingerprint: currentSourceSnapshot.fingerprint, + generationRun, + }); + const isCampaignStale = campaignFreshness.isStale; + const sourceChangeLabels = isCampaignStale + ? getGenerationSourceChanges(generationRun?.sourceSnapshot, currentSourceSnapshot) + : []; const canPublishCurrent = Boolean( - currentConnection?.connected && !currentConnection?.expired && !currentConnection?.manualOnly, + campaignFreshness.canUseCurrentGeneration && + currentConnection?.connected && + !currentConnection?.expired && + !currentConnection?.manualOnly, ); const xThreadParts = activeChannel === "x" ? currentPost.split(/\n\n+/).map((part) => part.trim()).filter(Boolean) @@ -725,6 +753,13 @@ export default function Home() { })); } + function reportStaleCampaign() { + setMessage({ + type: "warning", + text: "Source inputs changed after generation. Regenerate the campaign before copying, exporting, or publishing these drafts.", + }); + } + function navigateStudioFlow(targetStage) { const nextStage = resolveStudioStage(targetStage, { hasSource: sourceSignals > 0, @@ -840,6 +875,13 @@ export default function Home() { return; } + const requestedSourceSnapshot = createGenerationSourceSnapshot({ + form, + channels, + files, + documentText, + }); + setBusy(true); setMessage(null); try { @@ -874,6 +916,13 @@ export default function Home() { } const generatedPosts = data.posts || {}; + const nextGenerationRun = createGenerationRun({ + sourceSnapshot: requestedSourceSnapshot, + response: data, + provider: form.provider, + model: form.model.trim(), + }); + setGenerationRun(nextGenerationRun); setResult(data); setPosts(generatedPosts); setActiveChannel(channels.find((channel) => generatedPosts[channel]) || channels[0]); @@ -912,6 +961,7 @@ export default function Home() { warnings: result.warnings || [], markdown: result.markdown || "", result, + generationRun, brief: { ...form, apiKey: "" }, publishOptions, ...createSourceSnapshot(files, documentText), @@ -927,12 +977,18 @@ export default function Home() { } function openCampaign(item) { + const restoredSource = restoreSourceSnapshot(item); + const restoredRun = restoreGenerationRun({ + ...item, + sourceFiles: restoredSource.sourceFiles, + documentText: restoredSource.documentText, + }); setForm((previous) => ({ ...previous, ...(item.brief || {}), apiKey: "" })); setChannels(item.channels || ["linkedin"]); setPosts(item.posts || {}); setResult(item.result || { markdown: item.markdown, warnings: item.warnings || [] }); + setGenerationRun(restoredRun); setPublishOptions(item.publishOptions || { reddit: { subreddit: "", title: "" } }); - const restoredSource = restoreSourceSnapshot(item); setFiles(restoredSource.sourceFiles); setDocumentText(restoredSource.documentText); setActiveChannel((item.channels || ["linkedin"])[0]); @@ -952,6 +1008,10 @@ export default function Home() { } async function copyCurrentPost(showMessage = true) { + if (isCampaignStale) { + reportStaleCampaign(); + return false; + } if (!currentPost) return false; try { if (navigator.clipboard?.writeText) { @@ -978,6 +1038,10 @@ export default function Home() { } async function copyAndOpenCurrent() { + if (isCampaignStale) { + reportStaleCampaign(); + return; + } let openedWindow = null; if (activeMeta.openUrl) { openedWindow = window.open(activeMeta.openUrl, "_blank"); @@ -1001,6 +1065,10 @@ export default function Home() { } function exportMarkdown() { + if (isCampaignStale) { + reportStaleCampaign(); + return; + } const name = (form.projectName || "signalflow-campaign") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") @@ -1017,6 +1085,10 @@ export default function Home() { } function exportJson() { + if (isCampaignStale) { + reportStaleCampaign(); + return; + } const name = (form.projectName || "signalflow-campaign") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") @@ -1029,6 +1101,10 @@ export default function Home() { } async function publishCurrentPost() { + if (isCampaignStale) { + reportStaleCampaign(); + return; + } if (!currentPost) return; if (!canPublishCurrent) { if (OFFICIAL_CONNECTORS.has(activeChannel)) { @@ -1233,7 +1309,12 @@ export default function Home() { )} {section === "studio" && ( -
+

@@ -1551,7 +1632,25 @@ export default function Home() {

) : ( -
+
+ {isCampaignStale && ( +
+
+ Source changed + These drafts belong to an earlier campaign snapshot. +
+

+ Review remains available, but SignalFlow blocks copy, export, and publishing until the + campaign is regenerated from the current source. +

+ {sourceChangeLabels.length > 0 && ( + Changed: {sourceChangeLabels.join(", ")}. + )} + +
+ )}
{channels.map((channelId) => { const meta = channelMeta(channelId); @@ -1584,12 +1683,22 @@ export default function Home() { {activeMeta.label} draft {activeMeta.tone}
- - {canPublishCurrent - ? "Direct publishing" - : OFFICIAL_CONNECTORS.has(activeChannel) - ? "Connector optional" - : "Export ready"} + + {isCampaignStale + ? "Source changed" + : canPublishCurrent + ? "Direct publishing" + : OFFICIAL_CONNECTORS.has(activeChannel) + ? "Connector optional" + : "Export ready"}
@@ -1629,7 +1738,18 @@ export default function Home() {

{activeMeta.label}

Voice
{activeMeta.tone}
-
Route
{canPublishCurrent ? "Connected official API" : OFFICIAL_CONNECTORS.has(activeChannel) ? "Official connector available; manual handoff remains available" : "Review, copy, export, and open-platform handoff"}
+
+
Route
+
+ {isCampaignStale + ? "Blocked until regeneration from the current source" + : canPublishCurrent + ? "Connected official API" + : OFFICIAL_CONNECTORS.has(activeChannel) + ? "Official connector available; manual handoff remains available" + : "Review, copy, export, and open-platform handoff"} +
+
Length
{xThreadMode ? `${xThreadParts.length} posts; longest is ${xLongestPart} of ${activeMeta.limit} characters` : activeMeta.limit ? `${currentPost.length.toLocaleString()} of ${activeMeta.limit.toLocaleString()} characters` : `${currentPost.length.toLocaleString()} characters; no fixed guide`}
Campaign context
{sourceSignals} source signal{sourceSignals === 1 ? "" : "s"}, {files.length} attached file{files.length === 1 ? "" : "s"}
@@ -1657,7 +1777,11 @@ export default function Home() {
-
@@ -1703,8 +1829,8 @@ export default function Home() { Take the full campaign with you Export every selected draft and the generation metadata. - - + + )} diff --git a/frontend/lib/studio/campaignFreshness.mjs b/frontend/lib/studio/campaignFreshness.mjs new file mode 100644 index 0000000..89ac978 --- /dev/null +++ b/frontend/lib/studio/campaignFreshness.mjs @@ -0,0 +1,230 @@ +export const CAMPAIGN_STATE_SCHEMA_VERSION = 1; + +const SOURCE_CHANGE_LABELS = { + projectName: "campaign name", + notes: "brief", + audience: "audience", + links: "links", + repository: "repository", + destinations: "destinations", + provider: "provider", + model: "model", + baseUrl: "endpoint", + documents: "document text", + media: "attached files", +}; + +function normalizeText(value) { + return String(value ?? "") + .replace(/\r\n?/g, "\n") + .trim(); +} + +function normalizeBaseUrl(value) { + return normalizeText(value).replace(/\/+$/g, ""); +} + +function normalizeStringList(values) { + return Array.from( + new Set( + (Array.isArray(values) ? values : []) + .map((value) => normalizeText(value)) + .filter(Boolean), + ), + ).sort((left, right) => left.localeCompare(right)); +} + +function sortCanonical(values) { + return [...values].sort((left, right) => stableSerialize(left).localeCompare(stableSerialize(right))); +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (!value || typeof value !== "object") return value; + return Object.keys(value) + .sort() + .reduce((result, key) => { + result[key] = canonicalize(value[key]); + return result; + }, {}); +} + +export function stableSerialize(value) { + return JSON.stringify(canonicalize(value)); +} + +function fnv1a64(value) { + let hash = 0xcbf29ce484222325n; + const prime = 0x100000001b3n; + for (let index = 0; index < value.length; index += 1) { + hash ^= BigInt(value.charCodeAt(index)); + hash = BigInt.asUintN(64, hash * prime); + } + return hash.toString(16).padStart(16, "0"); +} + +export function normalizeGenerationSource({ form = {}, channels = [], files = [], documentText = [] } = {}) { + const media = sortCanonical( + (Array.isArray(files) ? files : []).map((file) => ({ + name: normalizeText(file?.name), + type: normalizeText(file?.type || "file").toLowerCase(), + size: Math.max(0, Number(file?.size) || 0), + description: normalizeText(file?.description), + })), + ); + + return { + schemaVersion: CAMPAIGN_STATE_SCHEMA_VERSION, + campaign: { + projectName: normalizeText(form.projectName) || "Untitled campaign", + notes: normalizeText(form.notes), + audience: normalizeText(form.audience), + links: normalizeText(form.links), + repository: normalizeText(form.repo), + }, + destinations: normalizeStringList(channels), + modelRoute: { + provider: normalizeText(form.provider).toLowerCase(), + model: normalizeText(form.model), + baseUrl: normalizeBaseUrl(form.baseUrl), + }, + documents: normalizeStringList(documentText), + media, + }; +} + +export function createSourceFingerprint(source) { + const normalized = source?.schemaVersion === CAMPAIGN_STATE_SCHEMA_VERSION + ? source + : normalizeGenerationSource(source); + return `sf1-${fnv1a64(stableSerialize(normalized))}`; +} + +export function createGenerationSourceSnapshot(input, { createdAt = new Date().toISOString() } = {}) { + const normalizedSource = normalizeGenerationSource(input); + const fingerprint = createSourceFingerprint(normalizedSource); + return { + schemaVersion: CAMPAIGN_STATE_SCHEMA_VERSION, + sourceSnapshotId: `source-${fingerprint.slice(4)}`, + fingerprint, + normalizedSource, + createdAt, + }; +} + +export function createGenerationRun({ + sourceSnapshot, + response = {}, + provider = "", + model = "", + createdAt = new Date().toISOString(), + generationRunId, +} = {}) { + if (!sourceSnapshot?.fingerprint || !sourceSnapshot?.normalizedSource) { + throw new TypeError("A valid source snapshot is required to create a generation run."); + } + + const resolvedId = normalizeText( + generationRunId || response.generationRunId || response.generation_run_id || response.requestId || response.request_id, + ); + + return { + schemaVersion: CAMPAIGN_STATE_SCHEMA_VERSION, + generationRunId: resolvedId || `run-${sourceSnapshot.fingerprint.slice(4)}-${Date.parse(createdAt) || 0}`, + sourceSnapshotId: sourceSnapshot.sourceSnapshotId, + sourceFingerprint: sourceSnapshot.fingerprint, + sourceSnapshot, + provider: normalizeText(response.providerUsed || response.provider || provider), + model: normalizeText(response.modelUsed || response.model || model), + createdAt, + }; +} + +export function restoreGenerationRun(item) { + const stored = item?.generationRun; + const storedSnapshot = stored?.sourceSnapshot; + const storedFingerprint = storedSnapshot?.normalizedSource + ? createSourceFingerprint(storedSnapshot.normalizedSource) + : ""; + if ( + stored?.schemaVersion === CAMPAIGN_STATE_SCHEMA_VERSION && + storedSnapshot?.schemaVersion === CAMPAIGN_STATE_SCHEMA_VERSION && + stored?.sourceFingerprint && + storedSnapshot?.fingerprint === stored.sourceFingerprint && + storedFingerprint === stored.sourceFingerprint + ) { + return stored; + } + + const hasGeneratedContent = Boolean(item?.result || item?.markdown || Object.keys(item?.posts || {}).length); + if (!hasGeneratedContent) return null; + + const sourceSnapshot = createGenerationSourceSnapshot( + { + form: item?.brief || {}, + channels: item?.channels || [], + files: item?.sourceFiles || item?.files || [], + documentText: item?.documentText || [], + }, + { createdAt: item?.updatedAt || item?.createdAt || null }, + ); + + return createGenerationRun({ + sourceSnapshot, + response: item?.result || {}, + provider: item?.providerUsed || item?.brief?.provider, + model: item?.brief?.model, + createdAt: item?.updatedAt || item?.createdAt || new Date(0).toISOString(), + generationRunId: `legacy-${item?.id || sourceSnapshot.fingerprint.slice(4)}`, + }); +} + +export function getCampaignFreshness({ hasResult = false, currentSourceFingerprint = "", generationRun = null } = {}) { + if (!hasResult) { + return { + status: "empty", + isStale: false, + canUseCurrentGeneration: false, + }; + } + + if (!generationRun?.sourceFingerprint) { + return { + status: "untracked", + isStale: true, + canUseCurrentGeneration: false, + }; + } + + const isStale = generationRun.sourceFingerprint !== currentSourceFingerprint; + return { + status: isStale ? "stale" : "current", + isStale, + canUseCurrentGeneration: !isStale, + }; +} + +export function getGenerationSourceChanges(previousSnapshot, currentSnapshot) { + const previous = previousSnapshot?.normalizedSource; + const current = currentSnapshot?.normalizedSource; + if (!previous || !current) return []; + + const changes = []; + const compare = (key, left, right) => { + if (stableSerialize(left) !== stableSerialize(right)) changes.push(SOURCE_CHANGE_LABELS[key]); + }; + + compare("projectName", previous.campaign?.projectName, current.campaign?.projectName); + compare("notes", previous.campaign?.notes, current.campaign?.notes); + compare("audience", previous.campaign?.audience, current.campaign?.audience); + compare("links", previous.campaign?.links, current.campaign?.links); + compare("repository", previous.campaign?.repository, current.campaign?.repository); + compare("destinations", previous.destinations, current.destinations); + compare("provider", previous.modelRoute?.provider, current.modelRoute?.provider); + compare("model", previous.modelRoute?.model, current.modelRoute?.model); + compare("baseUrl", previous.modelRoute?.baseUrl, current.modelRoute?.baseUrl); + compare("documents", previous.documents, current.documents); + compare("media", previous.media, current.media); + + return changes; +} diff --git a/frontend/tests/campaignFreshness.test.mjs b/frontend/tests/campaignFreshness.test.mjs new file mode 100644 index 0000000..3250db1 --- /dev/null +++ b/frontend/tests/campaignFreshness.test.mjs @@ -0,0 +1,217 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +import { + CAMPAIGN_STATE_SCHEMA_VERSION, + createGenerationRun, + createGenerationSourceSnapshot, + getCampaignFreshness, + getGenerationSourceChanges, + restoreGenerationRun, +} from "../lib/studio/campaignFreshness.mjs"; + +function baseInput() { + return { + form: { + projectName: "SignalFlow launch", + notes: "A factual product brief.", + audience: "Founders and builders", + links: "https://example.com/docs", + repo: "https://github.com/example/signalflow", + provider: "gemini", + apiKey: "temporary-secret", + model: "gemini-2.5-flash", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/", + }, + channels: ["linkedin", "x"], + files: [ + { + name: "brief.md", + type: "text/markdown", + size: 42, + extracted: true, + description: "Text content extracted in the browser.", + }, + ], + documentText: ["FILE: brief.md\nEvidence"], + }; +} + +function fingerprint(input = baseInput()) { + return createGenerationSourceSnapshot(input, { createdAt: null }).fingerprint; +} + +const changes = [ + ["campaign name", (input) => { input.form.projectName = "A different launch"; }], + ["brief", (input) => { input.form.notes = "A changed product brief."; }], + ["audience", (input) => { input.form.audience = "Developer teams"; }], + ["links", (input) => { input.form.links = "https://example.com/new-docs"; }], + ["repository", (input) => { input.form.repo = "https://github.com/example/other"; }], + ["destinations", (input) => { input.channels.push("reddit"); }], + ["provider", (input) => { input.form.provider = "openai"; }], + ["model", (input) => { input.form.model = "gpt-5"; }], + ["endpoint", (input) => { input.form.baseUrl = "https://gateway.example/v1"; }], + ["document text", (input) => { input.documentText[0] += "\nNew evidence"; }], + ["attached file name", (input) => { input.files[0].name = "launch-brief.md"; }], + ["attached file type", (input) => { input.files[0].type = "text/plain"; }], + ["attached file size", (input) => { input.files[0].size = 43; }], + ["attached file description", (input) => { input.files[0].description = "Updated reference"; }], +]; + +for (const [label, mutate] of changes) { + test(`${label} changes invalidate the generation fingerprint`, () => { + const input = baseInput(); + const original = fingerprint(input); + mutate(input); + assert.notEqual(fingerprint(input), original); + }); +} + +test("temporary API key changes do not invalidate generated content", () => { + const input = baseInput(); + const original = fingerprint(input); + input.form.apiKey = "a-different-temporary-secret"; + assert.equal(fingerprint(input), original); +}); + +test("browser-only extraction metadata does not invalidate generated content", () => { + const input = baseInput(); + const original = fingerprint(input); + input.files[0].extracted = false; + assert.equal(fingerprint(input), original); +}); + +test("an empty campaign name matches the request payload's Untitled campaign default", () => { + const unnamed = baseInput(); + unnamed.form.projectName = ""; + const explicit = baseInput(); + explicit.form.projectName = "Untitled campaign"; + assert.equal(fingerprint(unnamed), fingerprint(explicit)); +}); + +test("normalization keeps equivalent whitespace, line endings, destination order, and trailing slashes stable", () => { + const original = baseInput(); + const equivalent = baseInput(); + equivalent.form.notes = " A factual product brief.\r\n"; + equivalent.form.baseUrl = "https://generativelanguage.googleapis.com/v1beta"; + equivalent.channels = ["x", "linkedin", "linkedin"]; + equivalent.documentText = [" FILE: brief.md\r\nEvidence "]; + assert.equal(fingerprint(equivalent), fingerprint(original)); +}); + +test("freshness blocks untracked and changed generations but accepts the matching snapshot", () => { + const sourceSnapshot = createGenerationSourceSnapshot(baseInput(), { createdAt: "2026-07-27T00:00:00.000Z" }); + const generationRun = createGenerationRun({ + sourceSnapshot, + provider: "gemini", + model: "gemini-2.5-flash", + createdAt: "2026-07-27T00:00:01.000Z", + }); + + assert.deepEqual( + getCampaignFreshness({ hasResult: true, currentSourceFingerprint: sourceSnapshot.fingerprint, generationRun }), + { status: "current", isStale: false, canUseCurrentGeneration: true }, + ); + assert.deepEqual( + getCampaignFreshness({ hasResult: true, currentSourceFingerprint: "sf1-changed", generationRun }), + { status: "stale", isStale: true, canUseCurrentGeneration: false }, + ); + assert.deepEqual( + getCampaignFreshness({ hasResult: true, currentSourceFingerprint: sourceSnapshot.fingerprint, generationRun: null }), + { status: "untracked", isStale: true, canUseCurrentGeneration: false }, + ); +}); + +test("generation run and source snapshot survive save and reopen deterministically", () => { + const input = baseInput(); + const sourceSnapshot = createGenerationSourceSnapshot(input, { createdAt: "2026-07-27T00:00:00.000Z" }); + const generationRun = createGenerationRun({ + sourceSnapshot, + response: { request_id: "request-123", providerUsed: "gemini", modelUsed: "gemini-2.5-flash" }, + createdAt: "2026-07-27T00:00:01.000Z", + }); + const saved = JSON.parse(JSON.stringify({ generationRun })); + const restored = restoreGenerationRun(saved); + + assert.equal(restored.schemaVersion, CAMPAIGN_STATE_SCHEMA_VERSION); + assert.equal(restored.generationRunId, "request-123"); + assert.equal(restored.sourceFingerprint, sourceSnapshot.fingerprint); + assert.equal(fingerprint(input), restored.sourceFingerprint); + assert.equal( + getCampaignFreshness({ + hasResult: true, + currentSourceFingerprint: fingerprint(input), + generationRun: restored, + }).status, + "current", + ); +}); + +test("tampered stored freshness metadata is rebuilt from the saved campaign source", () => { + const input = baseInput(); + const sourceSnapshot = createGenerationSourceSnapshot(input, { createdAt: "2026-07-27T00:00:00.000Z" }); + const generationRun = createGenerationRun({ + sourceSnapshot, + generationRunId: "request-123", + createdAt: "2026-07-27T00:00:01.000Z", + }); + generationRun.sourceSnapshot.normalizedSource.campaign.notes = "Tampered notes"; + + const restored = restoreGenerationRun({ + id: "campaign-saved", + updatedAt: "2026-07-27T00:00:02.000Z", + brief: { ...input.form, apiKey: "" }, + channels: input.channels, + sourceFiles: input.files, + documentText: input.documentText, + posts: { linkedin: "Saved post" }, + result: { providerUsed: "gemini" }, + generationRun, + }); + + assert.equal(restored.generationRunId, "legacy-campaign-saved"); + assert.equal(restored.sourceFingerprint, fingerprint(input)); +}); + +test("legacy saved campaigns receive a deterministic generation run without persisting an API key", () => { + const input = baseInput(); + const restored = restoreGenerationRun({ + id: "campaign-legacy", + updatedAt: "2026-07-27T00:00:00.000Z", + brief: { ...input.form, apiKey: "" }, + channels: input.channels, + sourceFiles: input.files, + documentText: input.documentText, + posts: { linkedin: "Legacy post" }, + result: { providerUsed: "gemini" }, + }); + + assert.equal(restored.generationRunId, "legacy-campaign-legacy"); + assert.equal(restored.sourceFingerprint, fingerprint(input)); + assert.equal(JSON.stringify(restored).includes("temporary-secret"), false); +}); + +test("source change descriptions name only the changed generation inputs", () => { + const previous = createGenerationSourceSnapshot(baseInput(), { createdAt: null }); + const changed = baseInput(); + changed.form.notes = "Changed brief"; + changed.form.model = "another-model"; + changed.channels.push("reddit"); + const current = createGenerationSourceSnapshot(changed, { createdAt: null }); + + assert.deepEqual(getGenerationSourceChanges(previous, current), ["brief", "destinations", "model"]); +}); + +test("Studio renders a persistent stale warning and blocks outbound actions", async () => { + const page = await readFile(new URL("../app/page.js", import.meta.url), "utf8"); + const layout = await readFile(new URL("../app/layout.js", import.meta.url), "utf8"); + + assert.match(page, /className="campaign-stale-banner"/); + assert.match(page, /role="alert"/); + assert.match(page, /data-freshness=\{campaignFreshness\.status\}/); + assert.match(page, /disabled=\{isCampaignStale \|\| !currentPost\}/); + assert.match(page, /disabled=\{isCampaignStale\}/); + assert.match(page, /generationRun,/); + assert.match(layout, /campaign-freshness\.css/); +});