diff --git a/frontend/src/lib/components/sessions/sessionMirror.svelte.ts b/frontend/src/lib/components/sessions/sessionMirror.svelte.ts index f30d66ca45..657f120e98 100644 --- a/frontend/src/lib/components/sessions/sessionMirror.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMirror.svelte.ts @@ -998,9 +998,23 @@ async function flush(): Promise { let settledAny = false let leftForNext = false for (const [ws, w] of work) { - const out = await pushWorkspace(ws, w, email) - if (out.status === 'abort') return - if (out.status === 'off') { + // A push carries whole transcripts, and a workspace that keeps no backups is + // asked again on every page load: one this page has not heard from yet is asked + // with the listing first, which costs nothing to refuse. + let probe: 'on' | 'off' | 'failed' = 'on' + if (!wsState.has(ws)) { + const listing = await fetchListing(ws) + if (getCurrentUserEmail() !== email) return + probe = listing === 'off' || listing === 'failed' ? listing : 'on' + if (probe === 'on') wsState.set(ws, 'on') + } + if (probe === 'failed') { + backOff() + continue + } + const out = probe === 'off' ? undefined : await pushWorkspace(ws, w, email) + if (out?.status === 'abort') return + if (!out || out.status === 'off') { markOff(ws) await staleWorkspaceSync(ws, email) // Same rule as the loop above: a removal is worth keeping only for a session @@ -1189,22 +1203,28 @@ function unpackBackup( type BackupListing = Awaited> -/** The workspace's listing; `off` when it keeps no backups, `failed` when it could not be - * listed this time. */ -async function listWorkspace(ws: string, email: string): Promise { +/** The workspace's listing as the server answers it; `off` when it keeps no backups, + * `failed` when it could not be listed this time. */ +async function fetchListing(ws: string): Promise { let listing try { listing = await AiService.listAiSessionBackups({ workspace: ws }) } catch (e) { const status = statusOf(e) - if (status === 404 || status === 403) { - markOff(ws) - return 'off' - } + // 404: a build without object storage, or a storage whose resource is gone. + // 403: nothing this token may back up. + if (status === 404 || status === 403) return 'off' console.warn('Could not list session backups', e) return 'failed' } - if (!listing.enabled) { + return listing.enabled ? listing : 'off' +} + +/** `fetchListing`, with what it says of the workspace recorded for the page. */ +async function listWorkspace(ws: string, email: string): Promise { + const listing = await fetchListing(ws) + if (listing === 'failed') return 'failed' + if (listing === 'off') { markOff(ws) return 'off' } diff --git a/frontend/src/lib/components/sessions/sessionMirror.test.ts b/frontend/src/lib/components/sessions/sessionMirror.test.ts index 55a1acc138..9a8a211ded 100644 --- a/frontend/src/lib/components/sessions/sessionMirror.test.ts +++ b/frontend/src/lib/components/sessions/sessionMirror.test.ts @@ -150,6 +150,7 @@ beforeEach(async () => { __resetMirrorForTesting() pushMock.mockReset() listMock.mockReset() + listMock.mockResolvedValue({ enabled: true, sessions: [] }) pullMock.mockReset() chatImport.unavailable = false chatImport.pruneFails = false @@ -316,6 +317,30 @@ describe('sessionMirror flush', () => { expect((await __syncRowsForTesting(EMAIL)).some((r) => r.id === 'sr')).toBe(false) }) + it('asks a workspace it has not heard from before sending it a transcript', async () => { + const s: Session = { id: 'sp', name: 'session-1', createdAt: 1, workspace_id: 'ws' } + sessionState.sessions = [s] + await putSession(s) + const { ApiError } = await import('$lib/gen') + listMock.mockRejectedValueOnce( + new ApiError({ method: 'GET', url: '' } as never, { status: 404 } as never, 'no resource') + ) + await __flushForTesting() + expect(listMock).toHaveBeenCalledTimes(1) + expect(pushMock).not.toHaveBeenCalled() + expect(await pendingDirty()).toEqual(['sp']) + + // A page that heard the workspace keeps backups pushes without asking again. + __resetMirrorForTesting() + pushMock.mockResolvedValue({ enabled: true, results: [{ id: 'sp' }] }) + await __flushForTesting() + await putSession({ ...s, summary: 'changed' }) + await __flushForTesting() + expect(listMock).toHaveBeenCalledTimes(2) + expect(pushMock).toHaveBeenCalledTimes(2) + expect(await pendingDirty()).toEqual([]) + }) + it('keeps the marks when the push fails, and stops for a workspace without storage', async () => { const s: Session = { id: 's2', name: 'session-2', createdAt: 1, workspace_id: 'ws' } const never: Session = { id: 's2b', name: 'session-3', createdAt: 2, workspace_id: 'ws' } @@ -355,11 +380,11 @@ describe('sessionMirror flush', () => { // Backups an admin turns on again elsewhere are noticed once the page's memory of // them being off expires, with nothing else prompting it: the removal goes then. - listMock.mockResolvedValue({ enabled: true, sessions: [] }) + listMock.mockClear() pushMock.mockResolvedValueOnce({ enabled: true, results: [{ id: 's2' }] }) await vi.waitFor(() => expect(pushMock).toHaveBeenCalledTimes(4), { timeout: 3000 }) expect(pushMock.mock.calls[3][0].requestBody.removed).toEqual(['s2']) - await vi.waitFor(() => expect(listMock).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(listMock).toHaveBeenCalled()) await __settleForTesting() expect(pendingKeys()).toEqual([]) }) @@ -426,7 +451,7 @@ describe('sessionMirror flush', () => { await __flushForTesting() expect(pushMock).toHaveBeenCalledTimes(2) - listMock.mockResolvedValue({ enabled: true, sessions: [] }) + listMock.mockClear() pushMock.mockResolvedValueOnce({ enabled: true, results: [{ id: 'so' }] }) usersWorkspaceStore.set({ email: EMAIL, workspaces: [] } as never) backupSettingsChanged('ws')