diff --git a/docs/STATUS.md b/docs/STATUS.md index 4c0ab1d32..7d242ef9b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,6 +2,8 @@ Last Updated: 2026-09-10 +Original-source conflict recovery (#2808): a pagination revision conflict retracts the displayed originals and their selection immediately, then directs the user to refresh source metadata before selecting again. The browser proof changes a memory from revision 12 to 13 between selection and pagination, refreshes, and sends only the current revision. Server permission/revision enforcement remains unchanged. + Comparison appearance continuation (#2808): Auto observations retain both the selected mode and the resolved light/night appearance at submission. Existing version-2/3 files keep their original attribution; no historical appearance is invented. Frontend input fingerprints exclude repository test-only sources and build proof scripts, while runtime sources, public assets, build plugins and resolved build options remain attributed. This keeps manual comparisons coherent across proof-only edits without claiming randomized or statistical results. Source-storage export continuation (#2808): buffered and streamed account exports now hold one deferred SQLite read snapshot across blob objects, references, chunks, representations and audio-answer rows. Concurrent WAL uploads can commit without reserving the writer for the duration of the export; all five sections retain the earlier view until disposal. This is source-storage consistency, not a claim of a single snapshot across every account-export section or a tested restore workflow. diff --git a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md index 818e3796f..1dbe62b9d 100644 --- a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md +++ b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md @@ -376,3 +376,9 @@ Auto observations use the existing version-3 theme string to retain both selecte The HTTP LAN compatibility Chromium journey records both live color-scheme settings, confirms the body theme, exports those labels alongside a legacy auto observation, checks three separate result rows, reloads and imports all three without SubtleCrypto or randomUUID. It passes in 14.6 seconds. Node fingerprint checks verify that src/tests and build/*.node-check.mjs changes leave product attribution stable, while runtime source, public assets, build plugin code and build configuration change it. Full frontend/build results belong to the continuation PR; there is no telemetry, randomized assignment or statistical conclusion. Final local comparison-attribution gate: 6,377 frontend tests passed with three existing skips across 412 files. Production build/typecheck, two Node identity checks, the 14.6-second Chromium journey, documentation links and GitHub governance pass. Independent bounded Luna review is CLEAN. The test-only fingerprint exclusions are exact repository paths; this is still an input fingerprint, not a byte-for-byte bundle hash or proof of the remote server version. Temporary services are stopped; hosted checks remain separate. + +## Original-source selection recovery (2026-09-10) + +The shared original picker retracts items and emits an empty selection on HTTP 409, with explicit refresh guidance. Thirteen targeted component tests and typecheck pass. The real-API original-source Chromium journey changes a selected memory concurrently, receives the actual revision conflict on the next page, refreshes and reselects, and proves the outgoing context uses revision13. Receipt replay, all four experiences,403 recovery and375px accessibility remain covered; the journey passes in17.6seconds. + +Full frontend verification passes6,378tests with three existing skips across412files; production build/typecheck, documentation links/governance and diff checks pass. This addresses hosted comment3975016183. No backend authority changes, implicit retrieval or automatic send are introduced. Temporary services are stopped; hosted qualification and physical/provider acceptance remain separate. diff --git a/frontend/taskdeck-web/src/components/chat/ChatOriginalSourcePicker.vue b/frontend/taskdeck-web/src/components/chat/ChatOriginalSourcePicker.vue index 32a870aaa..e934466c6 100644 --- a/frontend/taskdeck-web/src/components/chat/ChatOriginalSourcePicker.vue +++ b/frontend/taskdeck-web/src/components/chat/ChatOriginalSourcePicker.vue @@ -38,9 +38,11 @@ async function load() { } catch (cause) { if (request === generation) { const status = (cause as { response?: { status?: number } }).response?.status - if (status === 403 || status === 404) { + if (status === 403 || status === 404 || status === 409) { items.value = []; loaded.value = false; nextAfterOrdinal.value = -1; emit('change', []) - error.value = 'You no longer have access to these private originals. Check board access before retrying.' + error.value = status === 409 + ? 'This memory changed. Refresh sources and clear selection before choosing its originals again.' + : 'You no longer have access to these private originals. Check board access before retrying.' } else error.value = 'Originals could not be checked. Retry, or refresh all sources if this memory changed.' } } finally { if (request === generation) loading.value = false } diff --git a/frontend/taskdeck-web/src/tests/components/chat/ChatOriginalSourcePicker.spec.ts b/frontend/taskdeck-web/src/tests/components/chat/ChatOriginalSourcePicker.spec.ts index 0f82ce855..2f7370cdc 100644 --- a/frontend/taskdeck-web/src/tests/components/chat/ChatOriginalSourcePicker.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/chat/ChatOriginalSourcePicker.spec.ts @@ -3,6 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import ChatOriginalSourcePicker from '../../../components/chat/ChatOriginalSourcePicker.vue' import { useSessionStore } from '../../../store/sessionStore' +import type { ChatAssetReference } from '../../../types/chat' const api = vi.hoisted(() => ({ list: vi.fn() })) vi.mock('../../../api/chatSourcesApi', () => ({ chatSourcesApi: api })) const asset = { id: 'a1', name: 'answer-revision-1.txt', contentHash: 'a'.repeat(64), byteSize: 23, supersededByAssetId: 'a2', excerpt: '', truncated: false, ordinal: 0 } @@ -67,6 +68,25 @@ describe('original source choice', () => { expect(wrapper.get('[role=alert]').text()).toContain('no longer have access') expect(wrapper.find('input').exists()).toBe(false); expect(wrapper.emitted('change')?.at(-1)).toEqual([[]]) }) + it('retracts a selected original after a pagination conflict and resumes with the refreshed revision', async () => { + api.list.mockResolvedValueOnce(fullPage()).mockRejectedValueOnce({ response: { status: 409 } }) + const { wrapper } = setup() + await wrapper.get('button').trigger('click'); await flushPromises() + await wrapper.findAll('input')[0]!.setValue(true) + const selection = wrapper.emitted('change')!.at(-1)![0] as ChatAssetReference[] + await wrapper.setProps({ selected: selection, selectedCount: 1 }) + await wrapper.get('button').trigger('click'); await flushPromises() + expect(wrapper.find('input').exists()).toBe(false) + expect(wrapper.emitted('change')?.at(-1)).toEqual([[]]) + expect(wrapper.get('[role=alert]').text()).toContain('This memory changed. Refresh sources') + await wrapper.setProps({ revision: 3, selected: [], selectedCount: 0 }) + api.list.mockResolvedValueOnce({ ...page(), revision: 3 }) + await wrapper.get('button').trigger('click'); await flushPromises() + expect(api.list).toHaveBeenLastCalledWith('m1', 'b1', 3, -1) + await wrapper.get('input').setValue(true) + expect(wrapper.emitted('change')?.at(-1)).toEqual([[{ memoryId: 'm1', revision: 3, assetId: 'a1', contentHash: asset.contentHash }]]) + wrapper.unmount() + }) it.each(['backwards', 'mismatched-next', 'unordered'])('rejects a malformed %s cursor page', async kind => { const candidate = fullPage() if (kind === 'backwards') candidate.items[0]!.ordinal = -1 diff --git a/frontend/taskdeck-web/tests/e2e/original-source-context.spec.ts b/frontend/taskdeck-web/tests/e2e/original-source-context.spec.ts index ef4ed4b50..de7271a00 100644 --- a/frontend/taskdeck-web/tests/e2e/original-source-context.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/original-source-context.spec.ts @@ -31,17 +31,24 @@ test('Companion explicitly selects an original answer and retains its receipt ac await companion.getByRole('button', { name: 'Choose sources', exact: true }).click() await expect(companion.getByRole('button', { name: 'Choose original sources for Independent evidence', exact: true })).toBeVisible() await companion.getByRole('button', { name: 'Choose original sources for Original uncertainty', exact: true }).click() + await companion.getByLabel(/answer-revision-1.txt/).check() + await assertOk(await request.put(`${API_BASE_URL}/workspace-memory/${memory.id}`, { headers, data: { title: memory.title, text: 'A concurrent correction after source selection.', status: 'needsReview', revision: 12 } }), 'revise memory after selection') + await companion.getByRole('button', { name: 'Load more originals for Original uncertainty', exact: true }).click() + await expect(companion.getByRole('alert')).toContainText('This memory changed. Refresh sources') + await expect(companion.getByLabel(/answer-revision-1.txt/)).toHaveCount(0) + await companion.getByRole('button', { name: 'Refresh sources and clear selection', exact: true }).click() + await companion.getByRole('button', { name: 'Choose original sources for Original uncertainty', exact: true }).click() const nextPage = page.waitForRequest(req => req.method() === 'GET' && req.url().includes(`/context-memory/${memory.id}/sources`) && new URL(req.url()).searchParams.get('afterOrdinal') === '9') await companion.getByRole('button', { name: 'Load more originals for Original uncertainty', exact: true }).click() await nextPage - await expect(companion.getByLabel(/answer-revision-12.txt/)).toBeVisible() + await expect(companion.getByLabel(/answer-revision-13.txt/)).toBeVisible() await companion.getByLabel(/answer-revision-1.txt/).check() await companion.getByLabel('Automation instruction').fill('What did I originally say?') const sent = page.waitForRequest(req => req.method() === 'POST' && /chat\/sessions\/[^/]+\/messages$/.test(new URL(req.url()).pathname)) await companion.getByRole('button', { name: 'Send Message', exact: true }).click() const selection = (await sent).postDataJSON().context expect(selection.memories).toEqual([]); expect(selection.assets).toHaveLength(1) - expect(selection.assets[0]).toMatchObject({ memoryId: memory.id, revision: 12 }) + expect(selection.assets[0]).toMatchObject({ memoryId: memory.id, revision: 13 }) expect(selection.assets[0].contentHash).toMatch(/^[a-f0-9]{64}$/) await expect(companion.getByText('Sources included in this turn (1)', { exact: true })).toBeVisible() for (const experience of ['classic', 'studio', 'companion', 'unified']) {