From 7d94d2cd5b4638ca250efa3271a3f2486bce1048 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:17:29 +0530 Subject: [PATCH 01/13] feat: add campaign freshness contracts --- frontend/lib/studio/campaignFreshness.mjs | 225 ++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 frontend/lib/studio/campaignFreshness.mjs diff --git a/frontend/lib/studio/campaignFreshness.mjs b/frontend/lib/studio/campaignFreshness.mjs new file mode 100644 index 0000000..13df736 --- /dev/null +++ b/frontend/lib/studio/campaignFreshness.mjs @@ -0,0 +1,225 @@ +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), + extracted: Boolean(file?.extracted), + description: normalizeText(file?.description), + })), + ); + + return { + schemaVersion: CAMPAIGN_STATE_SCHEMA_VERSION, + campaign: { + projectName: normalizeText(form.projectName), + 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; + if ( + stored?.sourceFingerprint && + stored?.sourceSnapshot?.fingerprint === stored.sourceFingerprint && + stored?.sourceSnapshot?.normalizedSource + ) { + 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; +} From 7f3302c635a34d8c4d8b87a38bbd89ad346e85cc Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:18:12 +0530 Subject: [PATCH 02/13] test: cover campaign source freshness --- frontend/tests/campaignFreshness.test.mjs | 173 ++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 frontend/tests/campaignFreshness.test.mjs diff --git a/frontend/tests/campaignFreshness.test.mjs b/frontend/tests/campaignFreshness.test.mjs new file mode 100644 index 0000000..1546e17 --- /dev/null +++ b/frontend/tests/campaignFreshness.test.mjs @@ -0,0 +1,173 @@ +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 files", (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("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("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/); +}); From 652463f23cbb4b87c6a6e4c00510db47af52daf4 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:18:32 +0530 Subject: [PATCH 03/13] style: add stale campaign warning --- frontend/app/campaign-freshness.css | 99 +++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 frontend/app/campaign-freshness.css 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; + } +} From da8954851a393a59a1d0c2812043a33e44770416 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:21:23 +0530 Subject: [PATCH 04/13] chore: stage campaign freshness integration --- scripts/apply-campaign-freshness.mjs | 128 +++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 scripts/apply-campaign-freshness.mjs diff --git a/scripts/apply-campaign-freshness.mjs b/scripts/apply-campaign-freshness.mjs new file mode 100644 index 0000000..2de3ac4 --- /dev/null +++ b/scripts/apply-campaign-freshness.mjs @@ -0,0 +1,128 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = process.cwd(); + +function replaceLiteral(relativePath, search, replacement) { + const path = resolve(root, relativePath); + const source = readFileSync(path, "utf8"); + const occurrences = source.split(search).length - 1; + if (occurrences !== 1) { + throw new Error(`${relativePath}: expected one matching block, found ${occurrences}`); + } + writeFileSync(path, source.replace(search, replacement)); +} + +const operations = [ + { + "path": "frontend/app/page.js", + "search": "import {\n evaluateProviderReadiness,\n pickRecommendedProvider,\n} from \"../lib/studio/providerReadiness.mjs\";", + "replacement": "import {\n evaluateProviderReadiness,\n pickRecommendedProvider,\n} from \"../lib/studio/providerReadiness.mjs\";\nimport {\n createGenerationRun,\n createGenerationSourceSnapshot,\n getCampaignFreshness,\n getGenerationSourceChanges,\n restoreGenerationRun,\n} from \"../lib/studio/campaignFreshness.mjs\";" + }, + { + "path": "frontend/app/page.js", + "search": " const [result, setResult] = useState(null);\n const [posts, setPosts] = useState({});", + "replacement": " const [result, setResult] = useState(null);\n const [generationRun, setGenerationRun] = useState(null);\n const [posts, setPosts] = useState({});" + }, + { + "path": "frontend/app/page.js", + "search": " const currentPost = posts[activeChannel] || \"\";\n const currentConnection = connections[activeChannel] || null;\n const canPublishCurrent = Boolean(\n currentConnection?.connected && !currentConnection?.expired && !currentConnection?.manualOnly,\n );", + "replacement": " const currentPost = posts[activeChannel] || \"\";\n const currentConnection = connections[activeChannel] || null;\n const currentSourceSnapshot = useMemo(\n () =>\n createGenerationSourceSnapshot(\n { form, channels, files, documentText },\n { createdAt: null },\n ),\n [form, channels, files, documentText],\n );\n const campaignFreshness = getCampaignFreshness({\n hasResult: Boolean(result),\n currentSourceFingerprint: currentSourceSnapshot.fingerprint,\n generationRun,\n });\n const isCampaignStale = campaignFreshness.isStale;\n const sourceChangeLabels = isCampaignStale\n ? getGenerationSourceChanges(generationRun?.sourceSnapshot, currentSourceSnapshot)\n : [];\n const canPublishCurrent = Boolean(\n campaignFreshness.canUseCurrentGeneration &&\n currentConnection?.connected &&\n !currentConnection?.expired &&\n !currentConnection?.manualOnly,\n );" + }, + { + "path": "frontend/app/page.js", + "search": " function updatePublishOption(platform, key, value) {\n setPublishOptions((previous) => ({\n ...previous,\n [platform]: { ...(previous[platform] || {}), [key]: value },\n }));\n }\n\n function navigateStudioFlow(targetStage) {", + "replacement": " function updatePublishOption(platform, key, value) {\n setPublishOptions((previous) => ({\n ...previous,\n [platform]: { ...(previous[platform] || {}), [key]: value },\n }));\n }\n\n function reportStaleCampaign() {\n setMessage({\n type: \"warning\",\n text: \"Source inputs changed after generation. Regenerate the campaign before copying, exporting, or publishing these drafts.\",\n });\n }\n\n function navigateStudioFlow(targetStage) {" + }, + { + "path": "frontend/app/page.js", + "search": " setBusy(true);\n setMessage(null);\n try {\n const response = await fetch(\"/api/launch_kit\", {", + "replacement": " const requestedSourceSnapshot = createGenerationSourceSnapshot({\n form,\n channels,\n files,\n documentText,\n });\n\n setBusy(true);\n setMessage(null);\n try {\n const response = await fetch(\"/api/launch_kit\", {" + }, + { + "path": "frontend/app/page.js", + "search": " const generatedPosts = data.posts || {};\n setResult(data);\n setPosts(generatedPosts);", + "replacement": " const generatedPosts = data.posts || {};\n const nextGenerationRun = createGenerationRun({\n sourceSnapshot: requestedSourceSnapshot,\n response: data,\n provider: form.provider,\n model: form.model.trim(),\n });\n setGenerationRun(nextGenerationRun);\n setResult(data);\n setPosts(generatedPosts);" + }, + { + "path": "frontend/app/page.js", + "search": " markdown: result.markdown || \"\",\n result,\n brief: { ...form, apiKey: \"\" },", + "replacement": " markdown: result.markdown || \"\",\n result,\n generationRun,\n brief: { ...form, apiKey: \"\" }," + }, + { + "path": "frontend/app/page.js", + "search": " function openCampaign(item) {\n setForm((previous) => ({ ...previous, ...(item.brief || {}), apiKey: \"\" }));\n setChannels(item.channels || [\"linkedin\"]);\n setPosts(item.posts || {});\n setResult(item.result || { markdown: item.markdown, warnings: item.warnings || [] });\n setPublishOptions(item.publishOptions || { reddit: { subreddit: \"\", title: \"\" } });\n const restoredSource = restoreSourceSnapshot(item);\n setFiles(restoredSource.sourceFiles);\n setDocumentText(restoredSource.documentText);\n setActiveChannel((item.channels || [\"linkedin\"])[0]);\n setStage(\"review\");\n navigateSection(\"studio\");\n }", + "replacement": " function openCampaign(item) {\n const restoredSource = restoreSourceSnapshot(item);\n const restoredRun = restoreGenerationRun({\n ...item,\n sourceFiles: restoredSource.sourceFiles,\n documentText: restoredSource.documentText,\n });\n setForm((previous) => ({ ...previous, ...(item.brief || {}), apiKey: \"\" }));\n setChannels(item.channels || [\"linkedin\"]);\n setPosts(item.posts || {});\n setResult(item.result || { markdown: item.markdown, warnings: item.warnings || [] });\n setGenerationRun(restoredRun);\n setPublishOptions(item.publishOptions || { reddit: { subreddit: \"\", title: \"\" } });\n setFiles(restoredSource.sourceFiles);\n setDocumentText(restoredSource.documentText);\n setActiveChannel((item.channels || [\"linkedin\"])[0]);\n setStage(\"review\");\n navigateSection(\"studio\");\n }" + }, + { + "path": "frontend/app/page.js", + "search": " async function copyCurrentPost(showMessage = true) {\n if (!currentPost) return false;", + "replacement": " async function copyCurrentPost(showMessage = true) {\n if (isCampaignStale) {\n reportStaleCampaign();\n return false;\n }\n if (!currentPost) return false;" + }, + { + "path": "frontend/app/page.js", + "search": " async function copyAndOpenCurrent() {\n let openedWindow = null;", + "replacement": " async function copyAndOpenCurrent() {\n if (isCampaignStale) {\n reportStaleCampaign();\n return;\n }\n let openedWindow = null;" + }, + { + "path": "frontend/app/page.js", + "search": " function exportMarkdown() {\n const name = (form.projectName || \"signalflow-campaign\")", + "replacement": " function exportMarkdown() {\n if (isCampaignStale) {\n reportStaleCampaign();\n return;\n }\n const name = (form.projectName || \"signalflow-campaign\")" + }, + { + "path": "frontend/app/page.js", + "search": " function exportJson() {\n const name = (form.projectName || \"signalflow-campaign\")", + "replacement": " function exportJson() {\n if (isCampaignStale) {\n reportStaleCampaign();\n return;\n }\n const name = (form.projectName || \"signalflow-campaign\")" + }, + { + "path": "frontend/app/page.js", + "search": " async function publishCurrentPost() {\n if (!currentPost) return;", + "replacement": " async function publishCurrentPost() {\n if (isCampaignStale) {\n reportStaleCampaign();\n return;\n }\n if (!currentPost) return;" + }, + { + "path": "frontend/app/page.js", + "search": "
", + "replacement": " " + }, + { + "path": "frontend/app/page.js", + "search": "
\n
", + "replacement": "
\n {isCampaignStale && (\n
\n
\n Source changed\n These drafts belong to an earlier campaign snapshot.\n
\n

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

\n {sourceChangeLabels.length > 0 && (\n Changed: {sourceChangeLabels.join(\", ")}.\n )}\n \n
\n )}\n
" + }, + { + "path": "frontend/app/page.js", + "search": " \n {canPublishCurrent\n ? \"Direct publishing\"\n : OFFICIAL_CONNECTORS.has(activeChannel)\n ? \"Connector optional\"\n : \"Export ready\"}\n ", + "replacement": " \n {isCampaignStale\n ? \"Source changed\"\n : canPublishCurrent\n ? \"Direct publishing\"\n : OFFICIAL_CONNECTORS.has(activeChannel)\n ? \"Connector optional\"\n : \"Export ready\"}\n " + }, + { + "path": "frontend/app/page.js", + "search": "
Route
{canPublishCurrent ? \"Connected official API\" : OFFICIAL_CONNECTORS.has(activeChannel) ? \"Official connector available; manual handoff remains available\" : \"Review, copy, export, and open-platform handoff\"}
", + "replacement": "
\n
Route
\n
\n {isCampaignStale\n ? \"Blocked until regeneration from the current source\"\n : canPublishCurrent\n ? \"Connected official API\"\n : OFFICIAL_CONNECTORS.has(activeChannel)\n ? \"Official connector available; manual handoff remains available\"\n : \"Review, copy, export, and open-platform handoff\"}\n
\n
" + }, + { + "path": "frontend/app/page.js", + "search": " ", + "replacement": " copyCurrentPost()}\n disabled={isCampaignStale || !currentPost}\n >\n Copy draft\n " + }, + { + "path": "frontend/app/page.js", + "search": " \n {canPublishCurrent\n ? \"Publish approved draft\"\n : activeMeta.openUrl\n ? `Copy & open ${activeMeta.label}`\n : \"Copy approved draft\"}\n \n ", + "replacement": " \n {isCampaignStale\n ? \"Regenerate to continue\"\n : canPublishCurrent\n ? \"Publish approved draft\"\n : activeMeta.openUrl\n ? `Copy & open ${activeMeta.label}`\n : \"Copy approved draft\"}\n \n " + }, + { + "path": "frontend/app/page.js", + "search": " \n ", + "replacement": " \n " + }, + { + "path": "frontend/app/layout.js", + "search": "import \"../app/studio-product.css\";", + "replacement": "import \"../app/studio-product.css\";\nimport \"../app/campaign-freshness.css\";" + } +]; + +for (const operation of operations) { + replaceLiteral(operation.path, operation.search, operation.replacement); +} + +console.log(`Applied ${operations.length} campaign freshness transformations.`); From 6a1aa405103f3654b2245358e45c1a8e74605393 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:22:50 +0530 Subject: [PATCH 05/13] ci: verify campaign freshness integration --- .../workflows/apply-campaign-freshness.yml | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/apply-campaign-freshness.yml diff --git a/.github/workflows/apply-campaign-freshness.yml b/.github/workflows/apply-campaign-freshness.yml new file mode 100644 index 0000000..565f002 --- /dev/null +++ b/.github/workflows/apply-campaign-freshness.yml @@ -0,0 +1,82 @@ +name: Apply campaign freshness integration + +on: + push: + branches: + - stabilization/campaign-state-integrity + paths: + - .github/workflows/apply-campaign-freshness.yml + - scripts/apply-campaign-freshness.mjs + +permissions: + contents: write + +jobs: + integrate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: stabilization/campaign-state-integrity + fetch-depth: 0 + + - name: Detect integration work + id: integration + shell: bash + run: | + if [ -f scripts/apply-campaign-freshness.mjs ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Node + if: steps.integration.outputs.run == 'true' + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Apply source transformation + if: steps.integration.outputs.run == 'true' + run: node scripts/apply-campaign-freshness.mjs + + - name: Install frontend dependencies + if: steps.integration.outputs.run == 'true' + working-directory: frontend + run: npm ci + + - name: Run frontend regression tests + if: steps.integration.outputs.run == 'true' + working-directory: frontend + run: npm test + + - name: Audit frontend production dependencies + if: steps.integration.outputs.run == 'true' + working-directory: frontend + run: npm audit --omit=dev --audit-level=high + + - name: Build frontend + if: steps.integration.outputs.run == 'true' + working-directory: frontend + run: npm run build + + - name: Run MCP tests + if: steps.integration.outputs.run == 'true' + working-directory: mcp + run: npm test + + - name: Remove temporary integration files + if: steps.integration.outputs.run == 'true' + run: rm .github/workflows/apply-campaign-freshness.yml scripts/apply-campaign-freshness.mjs + + - name: Commit verified integration + if: steps.integration.outputs.run == 'true' + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix: track campaign source freshness" + git push origin HEAD:stabilization/campaign-state-integrity From 8e0942dc234c586c9fe0a4a0d8ce4534f6e2899a Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:25:17 +0530 Subject: [PATCH 06/13] ci: run freshness integration on pull request --- .github/workflows/apply-campaign-freshness.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/apply-campaign-freshness.yml b/.github/workflows/apply-campaign-freshness.yml index 565f002..bfa58cc 100644 --- a/.github/workflows/apply-campaign-freshness.yml +++ b/.github/workflows/apply-campaign-freshness.yml @@ -1,23 +1,22 @@ name: Apply campaign freshness integration on: - push: + pull_request: branches: - - stabilization/campaign-state-integrity - paths: - - .github/workflows/apply-campaign-freshness.yml - - scripts/apply-campaign-freshness.mjs + - master + types: [opened, synchronize, reopened] permissions: contents: write jobs: integrate: + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - ref: stabilization/campaign-state-integrity + ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 - name: Detect integration work @@ -79,4 +78,4 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A git commit -m "fix: track campaign source freshness" - git push origin HEAD:stabilization/campaign-state-integrity + git push origin HEAD:${{ github.event.pull_request.head.ref }} From bb7d511581afc7cdf6681bc2aacab2aac3d41a93 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:26:56 +0530 Subject: [PATCH 07/13] ci: report freshness integration diagnostics --- .../workflows/apply-campaign-freshness.yml | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/apply-campaign-freshness.yml b/.github/workflows/apply-campaign-freshness.yml index bfa58cc..4b96593 100644 --- a/.github/workflows/apply-campaign-freshness.yml +++ b/.github/workflows/apply-campaign-freshness.yml @@ -8,6 +8,7 @@ on: permissions: contents: write + pull-requests: write jobs: integrate: @@ -38,40 +39,59 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Apply source transformation + id: transform if: steps.integration.outputs.run == 'true' - run: node scripts/apply-campaign-freshness.mjs + continue-on-error: true + shell: bash + run: node scripts/apply-campaign-freshness.mjs > integration-diagnostics.txt 2>&1 + + - name: Report transformation failure + if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + { + echo "### Campaign freshness integration diagnostic" + echo + echo '```text' + cat integration-diagnostics.txt + echo '```' + } > integration-comment.md + gh pr comment "${{ github.event.pull_request.number }}" --body-file integration-comment.md + exit 1 - name: Install frontend dependencies - if: steps.integration.outputs.run == 'true' + if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' working-directory: frontend run: npm ci - name: Run frontend regression tests - if: steps.integration.outputs.run == 'true' + if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' working-directory: frontend run: npm test - name: Audit frontend production dependencies - if: steps.integration.outputs.run == 'true' + if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' working-directory: frontend run: npm audit --omit=dev --audit-level=high - name: Build frontend - if: steps.integration.outputs.run == 'true' + if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' working-directory: frontend run: npm run build - name: Run MCP tests - if: steps.integration.outputs.run == 'true' + if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' working-directory: mcp run: npm test - name: Remove temporary integration files - if: steps.integration.outputs.run == 'true' + if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' run: rm .github/workflows/apply-campaign-freshness.yml scripts/apply-campaign-freshness.mjs - name: Commit verified integration - if: steps.integration.outputs.run == 'true' + if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' shell: bash run: | git config user.name "github-actions[bot]" From 001d2d3c843f1fe89a59305f36fdc61ad4047c4b Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:28:02 +0530 Subject: [PATCH 08/13] chore: correct freshness codemod escaping --- scripts/fix-campaign-freshness-codemod.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 scripts/fix-campaign-freshness-codemod.mjs diff --git a/scripts/fix-campaign-freshness-codemod.mjs b/scripts/fix-campaign-freshness-codemod.mjs new file mode 100644 index 0000000..0b925c9 --- /dev/null +++ b/scripts/fix-campaign-freshness-codemod.mjs @@ -0,0 +1,12 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +const path = "scripts/apply-campaign-freshness.mjs"; +const source = readFileSync(path, "utf8"); +const invalid = 'sourceChangeLabels.join(\\", ")'; +const corrected = 'sourceChangeLabels.join(\\", \\")'; + +if (!source.includes(invalid)) { + throw new Error("The expected campaign freshness codemod escape sequence was not found."); +} + +writeFileSync(path, source.replace(invalid, corrected)); From ecdfb7d166f275fbf87799ed3b567464b2af4fdf Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:28:27 +0530 Subject: [PATCH 09/13] ci: repair and verify freshness codemod --- .github/workflows/apply-campaign-freshness.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/apply-campaign-freshness.yml b/.github/workflows/apply-campaign-freshness.yml index 4b96593..1d8e834 100644 --- a/.github/workflows/apply-campaign-freshness.yml +++ b/.github/workflows/apply-campaign-freshness.yml @@ -38,6 +38,10 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json + - name: Correct temporary codemod escaping + if: steps.integration.outputs.run == 'true' + run: node scripts/fix-campaign-freshness-codemod.mjs + - name: Apply source transformation id: transform if: steps.integration.outputs.run == 'true' @@ -88,7 +92,7 @@ jobs: - name: Remove temporary integration files if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' - run: rm .github/workflows/apply-campaign-freshness.yml scripts/apply-campaign-freshness.mjs + run: rm .github/workflows/apply-campaign-freshness.yml scripts/apply-campaign-freshness.mjs scripts/fix-campaign-freshness-codemod.mjs - name: Commit verified integration if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' From f087593364a50b54ef63ab09f6a220109f66eca4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:58:56 +0000 Subject: [PATCH 10/13] fix: track campaign source freshness --- .../workflows/apply-campaign-freshness.yml | 105 ----------- frontend/app/layout.js | 1 + frontend/app/page.js | 166 +++++++++++++++--- integration-diagnostics.txt | 1 + scripts/apply-campaign-freshness.mjs | 128 -------------- scripts/fix-campaign-freshness-codemod.mjs | 12 -- 6 files changed, 148 insertions(+), 265 deletions(-) delete mode 100644 .github/workflows/apply-campaign-freshness.yml create mode 100644 integration-diagnostics.txt delete mode 100644 scripts/apply-campaign-freshness.mjs delete mode 100644 scripts/fix-campaign-freshness-codemod.mjs diff --git a/.github/workflows/apply-campaign-freshness.yml b/.github/workflows/apply-campaign-freshness.yml deleted file mode 100644 index 1d8e834..0000000 --- a/.github/workflows/apply-campaign-freshness.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Apply campaign freshness integration - -on: - pull_request: - branches: - - master - types: [opened, synchronize, reopened] - -permissions: - contents: write - pull-requests: write - -jobs: - integrate: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - - name: Detect integration work - id: integration - shell: bash - run: | - if [ -f scripts/apply-campaign-freshness.mjs ]; then - echo "run=true" >> "$GITHUB_OUTPUT" - else - echo "run=false" >> "$GITHUB_OUTPUT" - fi - - - name: Set up Node - if: steps.integration.outputs.run == 'true' - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: frontend/package-lock.json - - - name: Correct temporary codemod escaping - if: steps.integration.outputs.run == 'true' - run: node scripts/fix-campaign-freshness-codemod.mjs - - - name: Apply source transformation - id: transform - if: steps.integration.outputs.run == 'true' - continue-on-error: true - shell: bash - run: node scripts/apply-campaign-freshness.mjs > integration-diagnostics.txt 2>&1 - - - name: Report transformation failure - if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'failure' - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - { - echo "### Campaign freshness integration diagnostic" - echo - echo '```text' - cat integration-diagnostics.txt - echo '```' - } > integration-comment.md - gh pr comment "${{ github.event.pull_request.number }}" --body-file integration-comment.md - exit 1 - - - name: Install frontend dependencies - if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' - working-directory: frontend - run: npm ci - - - name: Run frontend regression tests - if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' - working-directory: frontend - run: npm test - - - name: Audit frontend production dependencies - if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' - working-directory: frontend - run: npm audit --omit=dev --audit-level=high - - - name: Build frontend - if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' - working-directory: frontend - run: npm run build - - - name: Run MCP tests - if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' - working-directory: mcp - run: npm test - - - name: Remove temporary integration files - if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' - run: rm .github/workflows/apply-campaign-freshness.yml scripts/apply-campaign-freshness.mjs scripts/fix-campaign-freshness-codemod.mjs - - - name: Commit verified integration - if: steps.integration.outputs.run == 'true' && steps.transform.outcome == 'success' - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix: track campaign source freshness" - git push origin HEAD:${{ github.event.pull_request.head.ref }} 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/integration-diagnostics.txt b/integration-diagnostics.txt new file mode 100644 index 0000000..018b642 --- /dev/null +++ b/integration-diagnostics.txt @@ -0,0 +1 @@ +Applied 21 campaign freshness transformations. diff --git a/scripts/apply-campaign-freshness.mjs b/scripts/apply-campaign-freshness.mjs deleted file mode 100644 index 2de3ac4..0000000 --- a/scripts/apply-campaign-freshness.mjs +++ /dev/null @@ -1,128 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const root = process.cwd(); - -function replaceLiteral(relativePath, search, replacement) { - const path = resolve(root, relativePath); - const source = readFileSync(path, "utf8"); - const occurrences = source.split(search).length - 1; - if (occurrences !== 1) { - throw new Error(`${relativePath}: expected one matching block, found ${occurrences}`); - } - writeFileSync(path, source.replace(search, replacement)); -} - -const operations = [ - { - "path": "frontend/app/page.js", - "search": "import {\n evaluateProviderReadiness,\n pickRecommendedProvider,\n} from \"../lib/studio/providerReadiness.mjs\";", - "replacement": "import {\n evaluateProviderReadiness,\n pickRecommendedProvider,\n} from \"../lib/studio/providerReadiness.mjs\";\nimport {\n createGenerationRun,\n createGenerationSourceSnapshot,\n getCampaignFreshness,\n getGenerationSourceChanges,\n restoreGenerationRun,\n} from \"../lib/studio/campaignFreshness.mjs\";" - }, - { - "path": "frontend/app/page.js", - "search": " const [result, setResult] = useState(null);\n const [posts, setPosts] = useState({});", - "replacement": " const [result, setResult] = useState(null);\n const [generationRun, setGenerationRun] = useState(null);\n const [posts, setPosts] = useState({});" - }, - { - "path": "frontend/app/page.js", - "search": " const currentPost = posts[activeChannel] || \"\";\n const currentConnection = connections[activeChannel] || null;\n const canPublishCurrent = Boolean(\n currentConnection?.connected && !currentConnection?.expired && !currentConnection?.manualOnly,\n );", - "replacement": " const currentPost = posts[activeChannel] || \"\";\n const currentConnection = connections[activeChannel] || null;\n const currentSourceSnapshot = useMemo(\n () =>\n createGenerationSourceSnapshot(\n { form, channels, files, documentText },\n { createdAt: null },\n ),\n [form, channels, files, documentText],\n );\n const campaignFreshness = getCampaignFreshness({\n hasResult: Boolean(result),\n currentSourceFingerprint: currentSourceSnapshot.fingerprint,\n generationRun,\n });\n const isCampaignStale = campaignFreshness.isStale;\n const sourceChangeLabels = isCampaignStale\n ? getGenerationSourceChanges(generationRun?.sourceSnapshot, currentSourceSnapshot)\n : [];\n const canPublishCurrent = Boolean(\n campaignFreshness.canUseCurrentGeneration &&\n currentConnection?.connected &&\n !currentConnection?.expired &&\n !currentConnection?.manualOnly,\n );" - }, - { - "path": "frontend/app/page.js", - "search": " function updatePublishOption(platform, key, value) {\n setPublishOptions((previous) => ({\n ...previous,\n [platform]: { ...(previous[platform] || {}), [key]: value },\n }));\n }\n\n function navigateStudioFlow(targetStage) {", - "replacement": " function updatePublishOption(platform, key, value) {\n setPublishOptions((previous) => ({\n ...previous,\n [platform]: { ...(previous[platform] || {}), [key]: value },\n }));\n }\n\n function reportStaleCampaign() {\n setMessage({\n type: \"warning\",\n text: \"Source inputs changed after generation. Regenerate the campaign before copying, exporting, or publishing these drafts.\",\n });\n }\n\n function navigateStudioFlow(targetStage) {" - }, - { - "path": "frontend/app/page.js", - "search": " setBusy(true);\n setMessage(null);\n try {\n const response = await fetch(\"/api/launch_kit\", {", - "replacement": " const requestedSourceSnapshot = createGenerationSourceSnapshot({\n form,\n channels,\n files,\n documentText,\n });\n\n setBusy(true);\n setMessage(null);\n try {\n const response = await fetch(\"/api/launch_kit\", {" - }, - { - "path": "frontend/app/page.js", - "search": " const generatedPosts = data.posts || {};\n setResult(data);\n setPosts(generatedPosts);", - "replacement": " const generatedPosts = data.posts || {};\n const nextGenerationRun = createGenerationRun({\n sourceSnapshot: requestedSourceSnapshot,\n response: data,\n provider: form.provider,\n model: form.model.trim(),\n });\n setGenerationRun(nextGenerationRun);\n setResult(data);\n setPosts(generatedPosts);" - }, - { - "path": "frontend/app/page.js", - "search": " markdown: result.markdown || \"\",\n result,\n brief: { ...form, apiKey: \"\" },", - "replacement": " markdown: result.markdown || \"\",\n result,\n generationRun,\n brief: { ...form, apiKey: \"\" }," - }, - { - "path": "frontend/app/page.js", - "search": " function openCampaign(item) {\n setForm((previous) => ({ ...previous, ...(item.brief || {}), apiKey: \"\" }));\n setChannels(item.channels || [\"linkedin\"]);\n setPosts(item.posts || {});\n setResult(item.result || { markdown: item.markdown, warnings: item.warnings || [] });\n setPublishOptions(item.publishOptions || { reddit: { subreddit: \"\", title: \"\" } });\n const restoredSource = restoreSourceSnapshot(item);\n setFiles(restoredSource.sourceFiles);\n setDocumentText(restoredSource.documentText);\n setActiveChannel((item.channels || [\"linkedin\"])[0]);\n setStage(\"review\");\n navigateSection(\"studio\");\n }", - "replacement": " function openCampaign(item) {\n const restoredSource = restoreSourceSnapshot(item);\n const restoredRun = restoreGenerationRun({\n ...item,\n sourceFiles: restoredSource.sourceFiles,\n documentText: restoredSource.documentText,\n });\n setForm((previous) => ({ ...previous, ...(item.brief || {}), apiKey: \"\" }));\n setChannels(item.channels || [\"linkedin\"]);\n setPosts(item.posts || {});\n setResult(item.result || { markdown: item.markdown, warnings: item.warnings || [] });\n setGenerationRun(restoredRun);\n setPublishOptions(item.publishOptions || { reddit: { subreddit: \"\", title: \"\" } });\n setFiles(restoredSource.sourceFiles);\n setDocumentText(restoredSource.documentText);\n setActiveChannel((item.channels || [\"linkedin\"])[0]);\n setStage(\"review\");\n navigateSection(\"studio\");\n }" - }, - { - "path": "frontend/app/page.js", - "search": " async function copyCurrentPost(showMessage = true) {\n if (!currentPost) return false;", - "replacement": " async function copyCurrentPost(showMessage = true) {\n if (isCampaignStale) {\n reportStaleCampaign();\n return false;\n }\n if (!currentPost) return false;" - }, - { - "path": "frontend/app/page.js", - "search": " async function copyAndOpenCurrent() {\n let openedWindow = null;", - "replacement": " async function copyAndOpenCurrent() {\n if (isCampaignStale) {\n reportStaleCampaign();\n return;\n }\n let openedWindow = null;" - }, - { - "path": "frontend/app/page.js", - "search": " function exportMarkdown() {\n const name = (form.projectName || \"signalflow-campaign\")", - "replacement": " function exportMarkdown() {\n if (isCampaignStale) {\n reportStaleCampaign();\n return;\n }\n const name = (form.projectName || \"signalflow-campaign\")" - }, - { - "path": "frontend/app/page.js", - "search": " function exportJson() {\n const name = (form.projectName || \"signalflow-campaign\")", - "replacement": " function exportJson() {\n if (isCampaignStale) {\n reportStaleCampaign();\n return;\n }\n const name = (form.projectName || \"signalflow-campaign\")" - }, - { - "path": "frontend/app/page.js", - "search": " async function publishCurrentPost() {\n if (!currentPost) return;", - "replacement": " async function publishCurrentPost() {\n if (isCampaignStale) {\n reportStaleCampaign();\n return;\n }\n if (!currentPost) return;" - }, - { - "path": "frontend/app/page.js", - "search": "
", - "replacement": " " - }, - { - "path": "frontend/app/page.js", - "search": "
\n
", - "replacement": "
\n {isCampaignStale && (\n
\n
\n Source changed\n These drafts belong to an earlier campaign snapshot.\n
\n

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

\n {sourceChangeLabels.length > 0 && (\n Changed: {sourceChangeLabels.join(\", ")}.\n )}\n \n
\n )}\n
" - }, - { - "path": "frontend/app/page.js", - "search": " \n {canPublishCurrent\n ? \"Direct publishing\"\n : OFFICIAL_CONNECTORS.has(activeChannel)\n ? \"Connector optional\"\n : \"Export ready\"}\n ", - "replacement": " \n {isCampaignStale\n ? \"Source changed\"\n : canPublishCurrent\n ? \"Direct publishing\"\n : OFFICIAL_CONNECTORS.has(activeChannel)\n ? \"Connector optional\"\n : \"Export ready\"}\n " - }, - { - "path": "frontend/app/page.js", - "search": "
Route
{canPublishCurrent ? \"Connected official API\" : OFFICIAL_CONNECTORS.has(activeChannel) ? \"Official connector available; manual handoff remains available\" : \"Review, copy, export, and open-platform handoff\"}
", - "replacement": "
\n
Route
\n
\n {isCampaignStale\n ? \"Blocked until regeneration from the current source\"\n : canPublishCurrent\n ? \"Connected official API\"\n : OFFICIAL_CONNECTORS.has(activeChannel)\n ? \"Official connector available; manual handoff remains available\"\n : \"Review, copy, export, and open-platform handoff\"}\n
\n
" - }, - { - "path": "frontend/app/page.js", - "search": " ", - "replacement": " copyCurrentPost()}\n disabled={isCampaignStale || !currentPost}\n >\n Copy draft\n " - }, - { - "path": "frontend/app/page.js", - "search": " \n {canPublishCurrent\n ? \"Publish approved draft\"\n : activeMeta.openUrl\n ? `Copy & open ${activeMeta.label}`\n : \"Copy approved draft\"}\n \n ", - "replacement": " \n {isCampaignStale\n ? \"Regenerate to continue\"\n : canPublishCurrent\n ? \"Publish approved draft\"\n : activeMeta.openUrl\n ? `Copy & open ${activeMeta.label}`\n : \"Copy approved draft\"}\n \n " - }, - { - "path": "frontend/app/page.js", - "search": " \n ", - "replacement": " \n " - }, - { - "path": "frontend/app/layout.js", - "search": "import \"../app/studio-product.css\";", - "replacement": "import \"../app/studio-product.css\";\nimport \"../app/campaign-freshness.css\";" - } -]; - -for (const operation of operations) { - replaceLiteral(operation.path, operation.search, operation.replacement); -} - -console.log(`Applied ${operations.length} campaign freshness transformations.`); diff --git a/scripts/fix-campaign-freshness-codemod.mjs b/scripts/fix-campaign-freshness-codemod.mjs deleted file mode 100644 index 0b925c9..0000000 --- a/scripts/fix-campaign-freshness-codemod.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; - -const path = "scripts/apply-campaign-freshness.mjs"; -const source = readFileSync(path, "utf8"); -const invalid = 'sourceChangeLabels.join(\\", ")'; -const corrected = 'sourceChangeLabels.join(\\", \\")'; - -if (!source.includes(invalid)) { - throw new Error("The expected campaign freshness codemod escape sequence was not found."); -} - -writeFileSync(path, source.replace(invalid, corrected)); From e1aac674675fe4ebd83eddabf1d21581d2b4fb13 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:29:46 +0530 Subject: [PATCH 11/13] chore: remove temporary integration diagnostics --- integration-diagnostics.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 integration-diagnostics.txt diff --git a/integration-diagnostics.txt b/integration-diagnostics.txt deleted file mode 100644 index 018b642..0000000 --- a/integration-diagnostics.txt +++ /dev/null @@ -1 +0,0 @@ -Applied 21 campaign freshness transformations. From 86b1a303173f383d9784ff205a1dfdf0da6cad99 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:32:15 +0530 Subject: [PATCH 12/13] fix: align freshness fingerprint with generation payload --- frontend/lib/studio/campaignFreshness.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/lib/studio/campaignFreshness.mjs b/frontend/lib/studio/campaignFreshness.mjs index 13df736..89ac978 100644 --- a/frontend/lib/studio/campaignFreshness.mjs +++ b/frontend/lib/studio/campaignFreshness.mjs @@ -69,7 +69,6 @@ export function normalizeGenerationSource({ form = {}, channels = [], files = [] name: normalizeText(file?.name), type: normalizeText(file?.type || "file").toLowerCase(), size: Math.max(0, Number(file?.size) || 0), - extracted: Boolean(file?.extracted), description: normalizeText(file?.description), })), ); @@ -77,7 +76,7 @@ export function normalizeGenerationSource({ form = {}, channels = [], files = [] return { schemaVersion: CAMPAIGN_STATE_SCHEMA_VERSION, campaign: { - projectName: normalizeText(form.projectName), + projectName: normalizeText(form.projectName) || "Untitled campaign", notes: normalizeText(form.notes), audience: normalizeText(form.audience), links: normalizeText(form.links), @@ -143,10 +142,16 @@ export function createGenerationRun({ 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 && - stored?.sourceSnapshot?.fingerprint === stored.sourceFingerprint && - stored?.sourceSnapshot?.normalizedSource + storedSnapshot?.fingerprint === stored.sourceFingerprint && + storedFingerprint === stored.sourceFingerprint ) { return stored; } From 3d331214a36aef21f601e0a6e3718557c89d484a Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:33:03 +0530 Subject: [PATCH 13/13] test: tighten campaign freshness normalization --- frontend/tests/campaignFreshness.test.mjs | 46 ++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/frontend/tests/campaignFreshness.test.mjs b/frontend/tests/campaignFreshness.test.mjs index 1546e17..3250db1 100644 --- a/frontend/tests/campaignFreshness.test.mjs +++ b/frontend/tests/campaignFreshness.test.mjs @@ -53,7 +53,10 @@ const changes = [ ["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 files", (input) => { input.files[0].description = "Updated reference"; }], + ["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) { @@ -72,6 +75,21 @@ test("temporary API key changes do not invalidate generated content", () => { 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(); @@ -130,6 +148,32 @@ test("generation run and source snapshot survive save and reopen deterministical ); }); +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({