diff --git a/lib/app/commands/drafts.js b/lib/app/commands/drafts.js index 2306202..9e46715 100644 --- a/lib/app/commands/drafts.js +++ b/lib/app/commands/drafts.js @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { draftNamed, draftSourcesOf, draftsListText, reverifiedOf, sameDraftSource, submitRequestOf, withoutDraft } from '../../distill.js'; +import { draftNamed, draftSourcesOf, draftsListText, reverifiedOf, sameDraftSource, submitRequestOf, withoutDraft, withoutSubmittedDraft } from '../../distill.js'; import { DISTILL_TIMEOUT_MS, draftWasCorrected } from '../distill-session.js'; import { failure, success } from './result.js'; @@ -58,7 +58,7 @@ async function publish(ports, draft, signal) { if (!started) return invalidatedDraft(draft); const answered = await started.response; if (!answered.ok) return failure(`"${draft.title}" was not submitted: ${answered.error}`); - await ports.drafts.update((list) => withoutDraft(list, draft.id)); + await ports.drafts.update((list) => withoutSubmittedDraft(list, draft)); return success(`Submitted "${draft.title}" to the EvoMap Hub for review (message ${answered.data?.message_id ?? 'pending'}).`); } diff --git a/lib/distill.js b/lib/distill.js index dd4c3ac..6b7ee13 100644 --- a/lib/distill.js +++ b/lib/distill.js @@ -89,6 +89,13 @@ export function withoutDraft(drafts, id) { return (Array.isArray(drafts) ? drafts : []).filter((draft) => draft.id !== id); } +// A same-id replacement saved while the Proxy was answering differs from the submitted snapshot and stays. +export function withoutSubmittedDraft(drafts, submitted) { + const snapshot = JSON.stringify(submitted); + const kept = drafts.filter((draft) => draft.id !== submitted.id || JSON.stringify(draft) !== snapshot); + return kept.length < drafts.length ? kept : undefined; +} + // `1` names the first listed draft; anything else is matched as an id prefix // so a copied id works too. export function draftNamed(drafts, selector) { diff --git a/test/app/draft-publication.test.js b/test/app/draft-publication.test.js index 6ccc9ed..6a01879 100644 --- a/test/app/draft-publication.test.js +++ b/test/app/draft-publication.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, utimesSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -254,6 +254,34 @@ test('explicit publication preserves the Proxy receipt and its complete bound bu assert.deepEqual(await ports.drafts.read(), []); }); +for (const [change, replace] of [ + ['a later creation time', (draft) => ({ ...draft, createdAt: draft.createdAt + 1, title: 'Replacement' })], + ['a different receipt at the same creation time', (draft) => ({ + ...draft, publish_receipt: { ...RECEIPT, receiptId: 'replacement-receipt' }, + })], +]) test(`a successful submit keeps a same-id draft with ${change}`, async (t) => { + const { ports, draft } = await withDraft({ ...RESULT, publishable: true, publish_receipt: RECEIPT }); + const dir = mkdtempSync(join(tmpdir(), 'evolver-draft-replacement-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const { drafts } = fileStoresOf({ dir, agent: 'testagent' }); + ports.drafts = drafts; + await drafts.update(() => [draft]); + + const replacement = replace(draft); + ports.proxy.request = async (method, path, body) => { + assert.equal(path, '/asset/submit'); + assert.deepEqual(body.assets, draft.assets); + assert.deepEqual(body.publish_receipt, RECEIPT); + await drafts.update(() => [replacement]); + return { ok: true, data: { message_id: 'submitted-original' } }; + }; + + const answer = await runCommand(ports, { sessionId: 's1', name: 'evolver-drafts', args: 'publish 1' }); + assert.equal(answer.ok, true, answer.text); + assert.match(answer.text, /submitted-original/); + assert.deepEqual(await drafts.read(), [replacement]); +}); + test('a Proxy-declared unpublishable draft is retained and explains the trusted verification requirement', async () => { const { ports, requests, draft } = await withDraft({ ...RESULT, publishable: false }); ports.proxy.request = async (method, path, body) => {