fix: ask a workspace for its session backups before pushing to it (#11641)

This commit is contained in:
Ruben Fiszel
2026-10-09 15:35:06 +02:00
committed by GitHub
parent 442357dbe8
commit 2ea846f563
2 changed files with 59 additions and 14 deletions
@@ -998,9 +998,23 @@ async function flush(): Promise<void> {
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<ReturnType<typeof AiService.listAiSessionBackups>>
/** 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<BackupListing | 'off' | 'failed'> {
/** 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<BackupListing | 'off' | 'failed'> {
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<BackupListing | 'off' | 'failed'> {
const listing = await fetchListing(ws)
if (listing === 'failed') return 'failed'
if (listing === 'off') {
markOff(ws)
return 'off'
}
@@ -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')