diff --git a/lib/app/commands/drafts.js b/lib/app/commands/drafts.js index 6648ce3..a00fb2d 100644 --- a/lib/app/commands/drafts.js +++ b/lib/app/commands/drafts.js @@ -6,8 +6,8 @@ import { failure, success } from './result.js'; const USAGE = 'Usage: /evolver-drafts [publish|discard ]'; -async function discard({ drafts, clock }, draft) { - await drafts.update((list) => withoutDraft(list, draft.id, clock.now())); +async function discard({ drafts }, draft) { + await drafts.update((list) => withoutDraft(list, draft.id)); return success(`Discarded "${draft.title}".`); } @@ -15,7 +15,7 @@ async function publish(ports, draft) { const request = submitRequestOf(draft); const answered = await ports.proxy.request(request.method, request.path, request.body); if (!answered.ok) return failure(`"${draft.title}" was not submitted: ${answered.error}`); - await ports.drafts.update((list) => withoutDraft(list, draft.id, ports.clock.now())); + await ports.drafts.update((list) => withoutDraft(list, draft.id)); return success(`Submitted "${draft.title}" to the EvoMap Hub for review (message ${answered.data?.message_id ?? 'pending'}).`); } @@ -25,7 +25,7 @@ export default { hint: '[publish|discard ]', async run(ports, { args }) { const [action = '', selector = ''] = args.trim().split(/\s+/); - const list = withoutDraft(await ports.drafts.read(), null, ports.clock.now()); + const list = await ports.drafts.read(); if (!action) return success(draftsListText(list)); if (action !== 'publish' && action !== 'discard') return failure(USAGE); const draft = draftNamed(list, selector); diff --git a/lib/app/commands/status.js b/lib/app/commands/status.js index 928f2a1..7a7ef86 100644 --- a/lib/app/commands/status.js +++ b/lib/app/commands/status.js @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { withoutDraft } from '../../distill.js'; import { engineNoticeOf } from '../../engine/notice.js'; import { SIGN_IN, signInOf } from '../../hub-login.js'; import { MIN_EVOLVER_VERSION } from '../../engine/version.js'; @@ -38,7 +37,7 @@ export default { description: 'Evolver health: the Proxy, engine setup, Hub sign-in, distillation candidate and drafts.', async run({ identity, proxy, engine, candidates, drafts, signIn, clock }, { sessionId, anySession }) { const answered = await proxy.request('GET', '/proxy/status', undefined, { timeoutMs: 3_000 }); - const waiting = withoutDraft(await drafts.read(), null, clock.now()).length; + const waiting = (await drafts.read()).length; return success([ answered.ok ? proxyStatusText(answered.data) : answered.error, engineLine(await engine.outcome(), identity.label), diff --git a/lib/app/distill-session.js b/lib/app/distill-session.js index 81e7617..a271e1b 100644 --- a/lib/app/distill-session.js +++ b/lib/app/distill-session.js @@ -27,7 +27,7 @@ async function post(ports, { sessionId, candidate }, args, usage, signal) { const body = distillBody({ platform: ports.identity.agent, sessionId: taskSessionId(ports.identity, sessionId), candidate, args, usage }); const result = await ports.proxy.request('POST', '/conversation/distill', body, { timeoutMs: DISTILL_TIMEOUT_MS, signal }); const outcome = distillOutcomeOf(result, { title: args.title, sessionId }, ports.clock.now()); - if (outcome.draft) await ports.drafts.update((drafts) => withDraft(drafts, outcome.draft, ports.clock.now())); + if (outcome.draft) await ports.drafts.update((drafts) => withDraft(drafts, outcome.draft)); return { ...outcome, ...(usage && { usage }) }; } diff --git a/lib/distill.js b/lib/distill.js index 27a8ed1..eb61aac 100644 --- a/lib/distill.js +++ b/lib/distill.js @@ -3,8 +3,6 @@ import { containsSecret, withoutSecrets } from './redact.js'; -export const DRAFTS_TTL_MS = 8 * 24 * 60 * 60 * 1000; -const MAX_DRAFTS = 20; const MAX_TRACE_ROWS = 8; const MAX_ARTIFACTS = 12; const COMMAND_MAX_CHARS = 180; @@ -136,23 +134,19 @@ export function draftOf(result, { title, sessionId }, now) { }; } -function prunedDrafts(drafts, now) { - return (Array.isArray(drafts) ? drafts : []).filter((draft) => now - Number(draft?.createdAt) <= DRAFTS_TTL_MS); -} - export function skipReasonOf(result) { if (result?.ok !== true) return String(result?.reason ?? 'skipped'); if (result.quality?.ok !== true) return String(result.quality?.reason ?? 'quality_gate'); return 'incomplete_draft'; } -export function withDraft(drafts, draft, now) { - const kept = prunedDrafts(drafts, now).filter((existing) => existing.id !== draft.id); - return [...kept, draft].slice(-MAX_DRAFTS); +export function withDraft(drafts, draft) { + const kept = (Array.isArray(drafts) ? drafts : []).filter((existing) => existing.id !== draft.id); + return [...kept, draft]; } -export function withoutDraft(drafts, id, now) { - return prunedDrafts(drafts, now).filter((draft) => draft.id !== id); +export function withoutDraft(drafts, id) { + return (Array.isArray(drafts) ? drafts : []).filter((draft) => draft.id !== id); } // `1` names the first listed draft; anything else is matched as an id prefix diff --git a/test/app/draft-retention.test.js b/test/app/draft-retention.test.js new file mode 100644 index 0000000..0f8bfd1 --- /dev/null +++ b/test/app/draft-retention.test.js @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { runCommand } from '../../lib/app/commands/index.js'; +import { withDraft } from '../../lib/distill.js'; +import { memoryPorts } from './memory-ports.js'; + +test('adding and listing drafts preserves old, legacy and more than twenty unreviewed drafts', async () => { + const { ports } = memoryPorts(); + const earlier = Array.from({ length: 25 }, (_, i) => ({ id: `old-${i}`, title: `Old method ${i}`, summary: '', assets: [], createdAt: 0 })); + earlier.push({ id: 'legacy', title: 'Legacy method', summary: '', assets: [] }); + await ports.drafts.update(() => earlier); + await ports.clock.sleep(9 * 24 * 60 * 60 * 1000); + const added = { id: 'new', title: 'New method', summary: '', assets: [], createdAt: ports.clock.now() }; + await ports.drafts.update((list) => withDraft(list, added)); + const list = await runCommand(ports, { sessionId: 's1', name: 'evolver-drafts' }); + assert.match(list.text, /27 distilled drafts waiting/); + assert.match(list.text, /1\. Old method 0/); + assert.deepEqual(await ports.drafts.read(), [...earlier, added]); + const discarded = await runCommand(ports, { sessionId: 's1', name: 'evolver-drafts', args: 'discard 1' }); + assert.equal(discarded.ok, true); + assert.deepEqual(await ports.drafts.read(), [...earlier.slice(1), added]); +}); diff --git a/test/distill.test.js b/test/distill.test.js index e6c514d..7fa33e1 100644 --- a/test/distill.test.js +++ b/test/distill.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { - DRAFTS_TTL_MS, distillPromptOf, distillRequestOf, draftNamed, draftOf, draftsListText, isDistillableTurn, lessonOf, skipReasonOf, + distillPromptOf, distillRequestOf, draftNamed, draftOf, draftsListText, isDistillableTurn, lessonOf, skipReasonOf, submitRequestOf, withDraft, withoutDraft, } from '../lib/distill.js'; @@ -87,15 +87,16 @@ test('a draft needs a passing quality gate and both assets', () => { assert.equal(skipReasonOf({ ok: true, quality: { ok: false, reason: 'insufficient_reusable_signal' } }), 'insufficient_reusable_signal'); }); -test('drafts are deduplicated by id, capped, and expire', () => { +test('drafts are deduplicated by id and retained until explicitly removed', () => { const draft = (id, createdAt = 0) => ({ id, title: id, summary: '', assets: [], createdAt }); - const once = withDraft(withDraft([], draft('a'), 0), draft('a', 1), 1); + const once = withDraft(withDraft([], draft('a')), draft('a', 1)); assert.deepEqual(once.map((d) => d.createdAt), [1]); - const many = Array.from({ length: 25 }, (_, i) => draft(`d${i}`)).reduce((list, d) => withDraft(list, d, 0), []); - assert.equal(many.length, 20); - assert.equal(many[0].id, 'd5'); - assert.deepEqual(withDraft([draft('old')], draft('new', DRAFTS_TTL_MS + 1), DRAFTS_TTL_MS + 1).map((d) => d.id), ['new']); - assert.deepEqual(withoutDraft([draft('a'), draft('b')], 'a', 0).map((d) => d.id), ['b']); + const many = Array.from({ length: 25 }, (_, i) => draft(`d${i}`)).reduce((list, d) => withDraft(list, d), []); + assert.equal(many.length, 25); + assert.equal(many[0].id, 'd0'); + const later = 9 * 24 * 60 * 60 * 1000; + assert.deepEqual(withDraft([draft('old')], draft('new', later)).map((d) => d.id), ['old', 'new']); + assert.deepEqual(withoutDraft([draft('a'), draft('b')], 'a').map((d) => d.id), ['b']); }); test('a draft is named by its list number or a unique id prefix', () => {