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.
This commit is contained in:
mesalogo
2026-10-06 15:40:18 +08:00
parent bc677a3718
commit d80d42b032
229 changed files with 13632 additions and 3800 deletions
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { AgentExecutionRequest, AgentRuntime, RuntimeModelUsageEvent } from '../agent/runtime'
import type {
MagicNoteEntry,
@@ -23,6 +23,17 @@ const entry: MagicNoteEntry = {
}
describe('magic note analyzer', () => {
it('awaits model usage persistence and rejects analysis if the write fails', async () => {
const { runtime } = recordingRuntime()
let reject!: (error: Error) => void
const persist = vi.fn(() => new Promise<void>((_resolve, fail) => { reject = fail }))
const pending = analyzeMagicNoteEntry(runtime, entry, options, undefined, persist)
const rejected = expect(pending).rejects.toThrow('Usage write failed')
await vi.waitFor(() => expect(persist).toHaveBeenCalledOnce())
reject(new Error('Usage write failed'))
await rejected
})
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII='
const canvasEntry = {
...entry,
@@ -127,7 +138,7 @@ describe('magic note analyzer', () => {
const usage: RuntimeModelUsageEvent[] = []
const comments = await analyzeMagicNoteEntry(
runtime, canvasEntry, options, undefined,
(event) => usage.push(event), { supportsImageInput: true, canvasPageCount: 2 }
(event) => { usage.push(event) }, { supportsImageInput: true, canvasPageCount: 2 }
)
expect(requests).toHaveLength(1)
expect(requests[0]?.images).toEqual([
+5 -5
View File
@@ -167,7 +167,7 @@ async function analyzeComments(
},
options: MagicNoteAnalysisOptions,
onText?: (delta: string) => void,
onModelUsage?: (event: RuntimeModelUsageEvent) => void
onModelUsage?: (event: RuntimeModelUsageEvent) => void | Promise<void>
): Promise<MagicNoteComment[]> {
const source = input.source.trim()
if (!source && !input.images?.length) {
@@ -238,7 +238,7 @@ ${outputInstructions}`,
}
}
} else if (event.type === 'model-usage') {
onModelUsage?.(event)
await onModelUsage?.(event)
} else if (event.type === 'tool') {
throw new Error('魔法笔记 AI 分析不允许工具调用')
} else if (event.type === 'generated-image') {
@@ -304,7 +304,7 @@ export async function analyzeMagicNoteEntry(
entry: MagicNoteEntry,
options: MagicNoteAnalysisOptions,
onText?: (delta: string) => void,
onModelUsage?: (event: RuntimeModelUsageEvent) => void,
onModelUsage?: (event: RuntimeModelUsageEvent) => void | Promise<void>,
context?: NoteAnalysisContext
): Promise<MagicNoteComment[]> {
const input = canvasAnalysisInput(entry.content, options, context?.supportsImageInput === true, context?.canvasPageCount)
@@ -328,7 +328,7 @@ export async function analyzeMagicNoteDraft(
plainText: string,
options: MagicNoteAnalysisOptions,
onText?: (delta: string) => void,
onModelUsage?: (event: RuntimeModelUsageEvent) => void,
onModelUsage?: (event: RuntimeModelUsageEvent) => void | Promise<void>,
context?: NoteAnalysisContext
): Promise<MagicNoteComment[]> {
const input = canvasAnalysisInput(context?.content, options, context?.supportsImageInput === true, context?.canvasPageCount)
@@ -352,7 +352,7 @@ export async function analyzeMagicTodo(
todo: MagicTodoItem,
options: MagicNoteAnalysisOptions,
onText?: (delta: string) => void,
onModelUsage?: (event: RuntimeModelUsageEvent) => void,
onModelUsage?: (event: RuntimeModelUsageEvent) => void | Promise<void>,
context?: NoteAnalysisContext
): Promise<MagicNoteComment[]> {
const input = canvasAnalysisInput(
@@ -9,7 +9,7 @@ import {
import { createDefaultModelRuntime } from '../agent/create-runtime'
import type { RuntimeModelUsageEvent } from '../agent/runtime'
import type { ApplicationSettingsStore } from '../application-settings-store'
import type { AssistantDatabase } from '../assistant/assistant-database'
import type { AssistantStoragePort, Awaitable } from '../assistant-storage-port'
import type { RuntimeSettingsStore } from '../runtime-settings-store'
import { assertTrustedSender } from '../trusted-ipc-sender'
import {
@@ -21,10 +21,10 @@ import { magicNotePlainText, validateMagicNoteContent } from './rich-content'
type AnalysisDependencies = {
window: BrowserWindow
assistantDatabase: AssistantDatabase
assistantDatabase: AssistantStoragePort
settingsStore: Pick<RuntimeSettingsStore, 'getResolvedSettings'>
applicationSettingsStore?: Pick<ApplicationSettingsStore, 'get'>
persistModelUsage: (event: RuntimeModelUsageEvent) => void
persistModelUsage: (event: RuntimeModelUsageEvent) => Awaitable<void>
safeRuntimeError: (error: unknown, fallback: string) => string
}
@@ -39,24 +39,24 @@ export function registerMagicNotesAnalysisIpcHandlers(
const { entryId, requestId, direction, format, canvasImages, canvasPageText, expectedRevision } =
magicNoteAnalyzeSchema.parse(input)
const settings = await settingsStore.getResolvedSettings()
const entry = assistantDatabase.getMagicNoteEntry(entryId)
const entry = await assistantDatabase.getMagicNoteEntry(entryId)
if (expectedRevision !== undefined && entry.revision !== expectedRevision) {
throw new Error('记录已被更新,请重新捕获并分析')
}
const note = assistantDatabase.getMagicNoteContext(entry.noteId)
const note = await assistantDatabase.getMagicNoteContext(entry.noteId)
const canvasPageCount = (await applicationSettingsStore?.get())?.magicNoteCanvasPageCount ?? 1
const analysisRuntime = createDefaultModelRuntime(
settings.workspacePath,
settings
)
assistantDatabase.createTask({
id: requestId,
title: `分析笔记:${note.title}`,
instructions: '使用无工具模型对笔记记录进行只读分析',
origin: 'assistant',
visible: false
})
try {
await assistantDatabase.createTask({
id: requestId,
title: `分析笔记:${note.title}`,
instructions: '使用无工具模型对笔记记录进行只读分析',
origin: 'assistant',
visible: false
})
const comments = await analyzeMagicNoteEntry(
analysisRuntime,
entry,
@@ -80,16 +80,16 @@ export function registerMagicNotesAnalysisIpcHandlers(
persistModelUsage,
{ supportsImageInput: settings.supportsImageInput === true, canvasPageCount }
)
const analyzedNote = assistantDatabase.saveMagicNoteAnalysis({
const analyzedNote = await assistantDatabase.saveMagicNoteAnalysis({
entryId,
expectedRevision: entry.revision,
comments
})
assistantDatabase.updateTaskStatus(requestId, 'completed')
await assistantDatabase.updateTaskStatus(requestId, 'completed')
return analyzedNote
} catch (error) {
const message = safeRuntimeError(error, '魔法笔记 AI 分析失败')
assistantDatabase.updateTaskStatus(requestId, 'failed', message)
await assistantDatabase.updateTaskStatus(requestId, 'failed', message)
throw new Error(message, { cause: error })
} finally {
try {
@@ -117,14 +117,14 @@ export function registerMagicNotesAnalysisIpcHandlers(
settings
)
const { requestId, direction, format, canvasImages, canvasPageText } = parsed
assistantDatabase.createTask({
id: requestId,
title: '分析未保存笔记草稿',
instructions: '使用无工具模型对未保存笔记草稿进行只读分析',
origin: 'assistant',
visible: false
})
try {
await assistantDatabase.createTask({
id: requestId,
title: '分析未保存笔记草稿',
instructions: '使用无工具模型对未保存笔记草稿进行只读分析',
origin: 'assistant',
visible: false
})
const comments = await analyzeMagicNoteDraft(
analysisRuntime,
plainText,
@@ -148,7 +148,7 @@ export function registerMagicNotesAnalysisIpcHandlers(
persistModelUsage,
{ supportsImageInput: settings.supportsImageInput === true, content, canvasPageCount }
)
assistantDatabase.updateTaskStatus(requestId, 'completed')
await assistantDatabase.updateTaskStatus(requestId, 'completed')
return {
id: randomUUID(),
comments,
@@ -157,7 +157,7 @@ export function registerMagicNotesAnalysisIpcHandlers(
}
} catch (error) {
const message = safeRuntimeError(error, '魔法笔记草稿 AI 分析失败')
assistantDatabase.updateTaskStatus(requestId, 'failed', message)
await assistantDatabase.updateTaskStatus(requestId, 'failed', message)
throw new Error(message, { cause: error })
} finally {
try {
@@ -183,9 +183,9 @@ export function registerMagicTodosAnalysisIpcHandlers(
const { todoId, requestId, direction, format, canvasImages, canvasPageText, sourceEntryRevision } =
magicTodoIdSchema.parse(input)
const settings = await settingsStore.getResolvedSettings()
const todo = assistantDatabase.getMagicTodo(todoId)
const todo = await assistantDatabase.getMagicTodo(todoId)
const canvasPageCount = (await applicationSettingsStore?.get())?.magicNoteCanvasPageCount ?? 1
const entry = assistantDatabase.getMagicNoteEntry(todo.entryId)
const entry = await assistantDatabase.getMagicNoteEntry(todo.entryId)
if (sourceEntryRevision !== undefined && entry.revision !== sourceEntryRevision) {
throw new Error('来源记录已被更新,请重新捕获并分析')
}
@@ -193,14 +193,14 @@ export function registerMagicTodosAnalysisIpcHandlers(
settings.workspacePath,
settings
)
assistantDatabase.createTask({
id: requestId,
title: `分析待办:${todo.title}`,
instructions: '使用无工具模型对魔法笔记待办进行只读分析',
origin: 'assistant',
visible: false
})
try {
await assistantDatabase.createTask({
id: requestId,
title: `分析待办:${todo.title}`,
instructions: '使用无工具模型对魔法笔记待办进行只读分析',
origin: 'assistant',
visible: false
})
const comments = await analyzeMagicTodo(
analysisRuntime,
todo,
@@ -224,17 +224,17 @@ export function registerMagicTodosAnalysisIpcHandlers(
persistModelUsage,
{ supportsImageInput: settings.supportsImageInput === true, content: entry.content, canvasPageCount }
)
const analyzedTodo = assistantDatabase.saveMagicTodoAnalysis({
const analyzedTodo = await assistantDatabase.saveMagicTodoAnalysis({
todoId,
expectedRevision: todo.revision,
sourceEntryRevision: entry.revision,
comments
})
assistantDatabase.updateTaskStatus(requestId, 'completed')
await assistantDatabase.updateTaskStatus(requestId, 'completed')
return analyzedTodo
} catch (error) {
const message = safeRuntimeError(error, '魔法笔记待办 AI 分析失败')
assistantDatabase.updateTaskStatus(requestId, 'failed', message)
await assistantDatabase.updateTaskStatus(requestId, 'failed', message)
throw new Error(message, { cause: error })
} finally {
try {
@@ -0,0 +1,49 @@
import type { BrowserWindow, IpcMainInvokeEvent, ipcMain } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import type { AssistantStoragePort } from '../assistant-storage-port'
import { ipcChannels } from '../../shared/ipc-channels'
import { registerMagicNotesIpcHandlers, registerMagicTodosIpcHandlers } from './magic-notes-ipc'
function fixture(database: Partial<AssistantStoragePort>) {
const handlers = new Map<string, Parameters<typeof ipcMain.handle>[1]>()
const register: typeof ipcMain.handle = (channel, handler) => { handlers.set(channel, handler) }
const webContents = { mainFrame: { url: 'file:///app.html' }, getURL: () => 'file:///app.html' }
const window = { webContents } as unknown as BrowserWindow
const event = { sender: webContents, senderFrame: webContents.mainFrame } as unknown as IpcMainInvokeEvent
registerMagicNotesIpcHandlers(register, window, database as AssistantStoragePort)
registerMagicTodosIpcHandlers(register, window, database as AssistantStoragePort)
return { invoke: (channel: string, input?: unknown, sender = event) => handlers.get(channel)!(sender, input) }
}
describe('async magic notes IPC', () => {
it('resolves list DTOs and preserves sender/schema checks before storage', async () => {
const listMagicNotes = vi.fn(async () => [])
const createMagicNote = vi.fn()
const f = fixture({ listMagicNotes, listMagicNoteTags: async () => [], createMagicNote })
await expect(f.invoke(ipcChannels.magicNotesList)).resolves.toEqual({ notes: [], tags: [] })
await expect(f.invoke(ipcChannels.magicNotesList, undefined, {} as IpcMainInvokeEvent)).rejects.toThrow('IPC')
expect(listMagicNotes).toHaveBeenCalledOnce()
await expect(f.invoke(ipcChannels.magicNotesCreate, {})).rejects.toThrow()
expect(createMagicNote).not.toHaveBeenCalled()
})
it('does not acknowledge deletion or read a todo note before its write commits', async () => {
let commit!: () => void
const deletion = new Promise<void>(resolve => { commit = resolve })
const f = fixture({ deleteMagicNote: () => deletion })
const pending = f.invoke(ipcChannels.magicNotesDelete, { noteId: '00000000-0000-4000-8000-000000000001' })
expect(pending).toBe(deletion)
commit()
await pending
const getMagicNote = vi.fn(async () => ({ id: 'note' }))
let save!: (value: unknown) => void
const updated = new Promise(resolve => { save = resolve })
const todos = fixture({ updateMagicTodo: (() => updated) as AssistantStoragePort['updateMagicTodo'], getMagicNote: getMagicNote as never })
const result = todos.invoke(ipcChannels.magicTodosUpdate, { todoId: '00000000-0000-4000-8000-000000000002', completed: true, expectedRevision: 0 })
await Promise.resolve()
expect(getMagicNote).not.toHaveBeenCalled()
save({ noteId: 'note' })
await expect(result).resolves.toEqual({ todo: { noteId: 'note' }, note: { id: 'note' } })
})
})
+20 -20
View File
@@ -13,18 +13,18 @@ import {
magicNoteUpdateSchema,
magicTodoUpdateSchema
} from '../../shared/magic-notes-contracts'
import type { AssistantDatabase } from '../assistant/assistant-database'
import type { AssistantStoragePort } from '../assistant-storage-port'
import { assertTrustedSender } from '../trusted-ipc-sender'
import { magicNotePlainText, validateMagicNoteContent } from './rich-content'
export function registerMagicNotesIpcHandlers(
registerHandler: typeof ipcMain.handle,
window: BrowserWindow,
assistantDatabase: AssistantDatabase
assistantDatabase: AssistantStoragePort
): void {
const resolveMagicNoteSource = (source?: MagicNoteSource): MagicNoteSource | undefined => {
const resolveMagicNoteSource = async (source?: MagicNoteSource): Promise<MagicNoteSource | undefined> => {
if (!source) return undefined
const conversation = assistantDatabase.getConversation(source.conversationId)
const conversation = await assistantDatabase.getConversation(source.conversationId)
const messages = new Map(conversation.messages.map((message) => [message.id, message]))
for (const id of source.messageIds) {
const message = messages.get(id)
@@ -32,7 +32,7 @@ export function registerMagicNotesIpcHandlers(
throw new Error('Invalid conversation source message')
}
}
const project = conversation.projectId ? assistantDatabase.getProject(conversation.projectId) : undefined
const project = conversation.projectId ? await assistantDatabase.getProject(conversation.projectId) : undefined
return {
kind: source.kind,
conversationId: conversation.id,
@@ -49,11 +49,11 @@ export function registerMagicNotesIpcHandlers(
return assistantDatabase.searchMagicNoteSummaries(query, limit)
})
registerHandler(ipcChannels.magicNotesList, (event) => {
registerHandler(ipcChannels.magicNotesList, async (event) => {
assertTrustedSender(event, window)
return {
notes: assistantDatabase.listMagicNotes(),
tags: assistantDatabase.listMagicNoteTags()
notes: await assistantDatabase.listMagicNotes(),
tags: await assistantDatabase.listMagicNoteTags()
}
})
@@ -65,7 +65,7 @@ export function registerMagicNotesIpcHandlers(
registerHandler(ipcChannels.magicNotesDeleteTag, (event, input: unknown) => {
assertTrustedSender(event, window)
const { tagId } = magicNoteTagDeleteSchema.parse(input)
assistantDatabase.deleteMagicNoteTag(tagId)
return assistantDatabase.deleteMagicNoteTag(tagId)
})
registerHandler(ipcChannels.magicNotesGet, (event, input: unknown) => {
@@ -74,10 +74,10 @@ export function registerMagicNotesIpcHandlers(
return assistantDatabase.getMagicNote(noteId)
})
registerHandler(ipcChannels.magicNotesCreate, (event, input: unknown) => {
registerHandler(ipcChannels.magicNotesCreate, async (event, input: unknown) => {
assertTrustedSender(event, window)
const parsed = magicNoteCreateSchema.parse(input)
return assistantDatabase.createMagicNote({ ...parsed, source: resolveMagicNoteSource(parsed.source) })
return assistantDatabase.createMagicNote({ ...parsed, source: await resolveMagicNoteSource(parsed.source) })
})
registerHandler(ipcChannels.magicNotesUpdate, (event, input: unknown) => {
@@ -90,19 +90,19 @@ export function registerMagicNotesIpcHandlers(
registerHandler(ipcChannels.magicNotesDelete, (event, input: unknown) => {
assertTrustedSender(event, window)
const { noteId } = magicNoteDeleteSchema.parse(input)
assistantDatabase.deleteMagicNote(noteId)
return assistantDatabase.deleteMagicNote(noteId)
})
registerHandler(
ipcChannels.magicNotesCreateEntry,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const parsed = magicNoteEntryCreateSchema.parse(input)
const content = validateMagicNoteContent(parsed.content)
return assistantDatabase.createMagicNoteEntry({
noteId: parsed.noteId,
content,
source: resolveMagicNoteSource(parsed.source),
source: await resolveMagicNoteSource(parsed.source),
plainText: magicNotePlainText(content)
})
}
@@ -136,13 +136,13 @@ export function registerMagicNotesIpcHandlers(
export function registerMagicTodosIpcHandlers(
registerHandler: typeof ipcMain.handle,
window: BrowserWindow,
assistantDatabase: AssistantDatabase
assistantDatabase: AssistantStoragePort
): void {
registerHandler(
ipcChannels.magicTodosList,
(event) => {
async (event) => {
assertTrustedSender(event, window)
return { todos: assistantDatabase.listMagicTodos() }
return { todos: await assistantDatabase.listMagicTodos() }
}
)
@@ -156,14 +156,14 @@ export function registerMagicTodosIpcHandlers(
registerHandler(
ipcChannels.magicTodosUpdate,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const todo = assistantDatabase.updateMagicTodo(
const todo = await assistantDatabase.updateMagicTodo(
magicTodoUpdateSchema.parse(input)
)
return {
todo,
note: assistantDatabase.getMagicNote(todo.noteId)
note: await assistantDatabase.getMagicNote(todo.noteId)
}
}
)