Files
goodbuddy/src/main/assistant-storage-startup.test.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

127 lines
5.6 KiB
TypeScript

import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'
import { beforeEach, expect, it, vi } from 'vitest'
import { ipcChannels } from '../shared/ipc-channels'
import type { AssistantStorageProgress } from '../shared/assistant-storage-contracts'
import { prepareAssistantStorage } from './assistant-storage-startup'
import { loadMainWindow } from './window'
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, input?: unknown) => unknown>(),
quit: vi.fn()
}))
vi.mock('electron', () => ({
app: { quit: mocks.quit },
ipcMain: {
handle: (channel: string, handler: (event: unknown, input?: unknown) => unknown) => mocks.handlers.set(channel, handler),
removeHandler: (channel: string) => mocks.handlers.delete(channel)
}
}))
vi.mock('./window', () => ({ loadMainWindow: vi.fn() }))
beforeEach(() => {
mocks.handlers.clear()
vi.clearAllMocks()
})
function deferred() {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((done, fail) => { resolve = done; reject = fail })
return { promise, resolve, reject }
}
function fixture() {
const webContents = { mainFrame: { url: 'app://main' }, getURL: () => 'app://main' }
const window = { webContents, isDestroyed: () => false, setProgressBar: vi.fn() } as unknown as BrowserWindow
const event = { sender: webContents, senderFrame: webContents.mainFrame } as unknown as IpcMainInvokeEvent
const controller = new AbortController()
const opening = deferred(), retrying = deferred(), closing = deferred()
const storage = { ready: opening.promise, retry: vi.fn(() => retrying.promise), close: vi.fn(() => closing.promise) }
let publish!: (progress: AssistantStorageProgress) => void
const create = vi.fn((listener: typeof publish) => { publish = listener; return storage })
const preparation = prepareAssistantStorage(window, create, controller.signal)
return { window, event, controller, opening, retrying, closing, storage, create, publish, preparation }
}
it('publishes utility progress and retries the same client before allowing startup', async () => {
const f = fixture()
f.publish({ stage: 'converting', processed: 1, total: 2, bytesBefore: 1024 })
expect(loadMainWindow).toHaveBeenCalledOnce()
expect(f.window.setProgressBar).toHaveBeenLastCalledWith(0.5)
f.opening.reject(new Error('Retry this startup'))
await vi.waitFor(() => expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(f.event)).toMatchObject({ stage: 'failed' }))
mocks.handlers.get(ipcChannels.storageUpgradeAction)!(f.event, 'retry')
await vi.waitFor(() => expect(f.storage.retry).toHaveBeenCalledOnce())
const completed = vi.fn()
void f.preparation.then(completed)
await Promise.resolve()
expect(completed).not.toHaveBeenCalled()
expect(f.create).toHaveBeenCalledOnce()
f.retrying.resolve()
await f.preparation
expect(mocks.handlers.size).toBe(0)
expect(f.window.setProgressBar).toHaveBeenLastCalledWith(-1)
expect(f.storage.close).not.toHaveBeenCalled()
})
it('keeps the client identity and waits for replacement readiness across repeated process failures', async () => {
const f = fixture()
f.opening.reject(new Error('Initial utility exited'))
await vi.waitFor(() => expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(f.event)).toMatchObject({ stage: 'failed' }))
mocks.handlers.get(ipcChannels.storageUpgradeAction)!(f.event, 'retry')
await vi.waitFor(() => expect(f.storage.retry).toHaveBeenCalledTimes(1))
f.retrying.reject(new Error('Replacement utility exited'))
await vi.waitFor(() => expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(f.event)).toMatchObject({ error: 'Replacement utility exited' }))
const replacement = deferred()
f.storage.retry.mockReturnValue(replacement.promise)
mocks.handlers.get(ipcChannels.storageUpgradeAction)!(f.event, 'retry')
await vi.waitFor(() => expect(f.storage.retry).toHaveBeenCalledTimes(2))
expect(f.create).toHaveBeenCalledTimes(1)
replacement.resolve()
await f.preparation
expect(f.storage.close).not.toHaveBeenCalled()
})
it('does not open an upgrade page when storage becomes ready without an upgrade', async () => {
const f = fixture()
f.opening.resolve()
await f.preparation
expect(loadMainWindow).not.toHaveBeenCalled()
expect(mocks.handlers.size).toBe(0)
})
it.each([false, true])('awaits utility close on quit while failed=%s', async failed => {
const f = fixture()
if (failed) {
f.opening.reject(new Error('Startup failed'))
await vi.waitFor(() => expect(loadMainWindow).toHaveBeenCalledOnce())
}
f.controller.abort()
await vi.waitFor(() => expect(f.storage.close).toHaveBeenCalledOnce())
const settled = vi.fn()
void f.preparation.then(settled)
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
f.closing.resolve()
await f.preparation
expect(f.storage.retry).not.toHaveBeenCalled()
expect(mocks.handlers.size).toBe(0)
})
it('rejects upgrade actions from an unrelated sender', async () => {
const f = fixture()
expect(() => mocks.handlers.get(ipcChannels.storageUpgradeAction)!({ sender: {} }, 'retry')).toThrow()
f.opening.resolve()
await f.preparation
})
it('closes the owner when readiness fails at the same time as quit and reports an unconfirmed close', async () => {
const f = fixture()
f.opening.reject(new Error('Storage exited'))
f.controller.abort()
await vi.waitFor(() => expect(f.storage.close).toHaveBeenCalledOnce())
const result = expect(f.preparation).rejects.toThrow('Drain unconfirmed')
f.closing.reject(new Error('Drain unconfirmed'))
await result
expect(mocks.handlers.size).toBe(0)
})