release: prepare Desktop 0.13.10

This commit is contained in:
mesalogo
2026-09-21 22:53:44 +08:00
parent 6c4c054180
commit a0b35d1192
24 changed files with 677 additions and 30 deletions
+83 -1
View File
@@ -3,6 +3,7 @@ import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
import { DatabaseSync } from 'node:sqlite'
import { ExecutionStatsReader } from './execution-stats-reader'
import { statSync } from 'node:fs'
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
@@ -1786,13 +1787,20 @@ function deleteProjectRecords(
}
export class AssistantDatabase {
private static readonly executionStatsCacheTtlMs = 30_000
private static readonly executionStatsCacheLimit = 8
private database?: DatabaseSync
private executionStatsReader?: ExecutionStatsReader
private readonly noteStorage: MagicNoteStorage
private readonly dirtyMagicNotes = new Set<string>()
private readonly pendingMagicNoteCleanup = new Set<string | undefined>()
private subagentProgress?: SubagentProgressStorage
private channelEventWrites = 0
private channelOutboxWrites = 0
private readonly executionStatsCache = new Map<
string,
{ value: ExecutionStats; cachedAt: number; dataVersion: number; totalChanges: number }
>()
constructor(
private readonly databasePath: string,
@@ -2196,6 +2204,9 @@ export class AssistantDatabase {
}
close(): void {
this.executionStatsReader?.close()
this.executionStatsReader = undefined
this.executionStatsCache.clear()
this.database?.close()
this.database = undefined
this.subagentProgress = undefined
@@ -4520,11 +4531,82 @@ export class AssistantDatabase {
activeRequestIds: ReadonlySet<string> = new Set()
): ExecutionStats {
const scope = executionStatsInputSchema.parse(input)
const cacheKey = 'conversationId' in scope
? `conversation:${scope.conversationId}`
: `project:${scope.projectId}`
const database = this.requireDatabase()
// Never retain values read from a transaction that its caller can roll back.
if (database.isTransaction || activeRequestIds.size > 0) {
this.executionStatsCache.clear()
return this.readExecutionStatsSnapshot(scope, activeRequestIds)
}
const dataVersion = Number((database.prepare('PRAGMA data_version').get() as { data_version: number }).data_version)
const totalChanges = Number((database.prepare('SELECT total_changes() AS total_changes').get() as { total_changes: number }).total_changes)
const cached = activeRequestIds.size === 0
? this.executionStatsCache.get(cacheKey)
: undefined
if (cached && cached.dataVersion === dataVersion && cached.totalChanges === totalChanges &&
Date.now() - cached.cachedAt < AssistantDatabase.executionStatsCacheTtlMs) {
this.executionStatsCache.delete(cacheKey)
this.executionStatsCache.set(cacheKey, cached)
return structuredClone(cached.value)
}
const result = this.readExecutionStatsSnapshot(scope, activeRequestIds)
this.executionStatsCache.delete(cacheKey)
this.executionStatsCache.set(cacheKey, {
value: structuredClone(result), cachedAt: Date.now(), dataVersion, totalChanges
})
while (this.executionStatsCache.size > AssistantDatabase.executionStatsCacheLimit) {
this.executionStatsCache.delete(this.executionStatsCache.keys().next().value!)
}
return result
}
getExecutionStatsAsync(
input: ExecutionStatsInput,
activeRequestIds: ReadonlySet<string>,
workerPath: string
): Promise<ExecutionStats> {
this.requireDatabase()
const scope = executionStatsInputSchema.parse(input)
if (this.databasePath === ':memory:') {
return Promise.resolve(this.getExecutionStats(scope, activeRequestIds))
}
this.executionStatsReader ??= new ExecutionStatsReader(this.databasePath, workerPath)
return this.executionStatsReader.read(scope, activeRequestIds)
}
openReadOnly(): void {
if (this.database) throw new Error('Database already open')
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
}
private readExecutionStatsSnapshot(
scope: ExecutionStatsInput,
activeRequestIds: ReadonlySet<string>
): ExecutionStats {
const database = this.requireDatabase()
if (database.isTransaction) return this.computeExecutionStats(scope, activeRequestIds)
database.exec('BEGIN')
try {
const result = this.computeExecutionStats(scope, activeRequestIds)
database.exec('COMMIT')
return result
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
private computeExecutionStats(
scope: ExecutionStatsInput,
activeRequestIds: ReadonlySet<string>
): ExecutionStats {
const database = this.requireDatabase()
const result: ExecutionStats = {
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: Date.now(), taskDurations: []
}
const database = this.requireDatabase()
const scopeId = 'conversationId' in scope ? scope.conversationId : scope.projectId
const scopeColumn = 'conversationId' in scope ? 'conversation_id' : 'project_id'
const durations = new Map<string, ExecutionStats['taskDurations'][number]>()
@@ -67,6 +67,58 @@ describe('execution duration evidence', () => {
expect(database.getExecutionStats({ conversationId: randomUUID() })).toMatchObject({ durationMs: 0, requestCount: 0 })
})
it('invalidates cached scopes for local messages, remote events and external deletion', () => {
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
database.saveLocalConversations([{
header: { id: conversationId, projectId, title: 'History', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained', state: 'complete', createdAt: epoch }]
}])
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1)
const id = request()
database.getExecutionStats({ projectId })
database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: id, type: 'done' } })
expect(database.getExecutionStats({ projectId }).incompleteRequestCount).toBe(1)
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
try {
raw.prepare('DELETE FROM messages WHERE conversation_id = ?').run(conversationId)
raw.prepare('DELETE FROM tasks WHERE id = ?').run(id)
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
expect(database.getExecutionStats({ projectId }).requestCount).toBe(0)
} finally { raw.close() }
})
it('does not cache transaction snapshots across rollback, or serve a closed connection', () => {
const connection = (database as unknown as { database: DatabaseSync }).database
const id = request()
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
connection.exec('BEGIN')
text(id)
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1)
connection.exec('ROLLBACK')
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
database.close()
expect(() => database.getExecutionStats({ conversationId })).toThrow()
database.initialize(directory)
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
})
it('bounds cache scopes, expires idle entries and isolates returned objects', () => {
const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare')
const reads = (): number => prepare.mock.calls.filter(([sql]) => sql.includes('WITH scoped AS MATERIALIZED')).length
try {
const first = database.getExecutionStats({ projectId })
first.requestCount = 999
expect(database.getExecutionStats({ projectId }).requestCount).toBe(0)
expect(reads()).toBe(1)
for (let index = 0; index < 8; index++) database.getExecutionStats({ conversationId: randomUUID() })
database.getExecutionStats({ projectId })
expect(reads()).toBe(10)
at(31)
database.getExecutionStats({ projectId })
expect(reads()).toBe(11)
} finally { prepare.mockRestore() }
})
it('excludes stable schedules, nested experts, and status-only maintenance but includes scheduled reply requests', () => {
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
database.updateTaskStatus(schedule.taskId, 'running')
@@ -0,0 +1,68 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { build } from 'esbuild'
import { afterAll, beforeAll, expect, it } from 'vitest'
import { AssistantDatabase } from './assistant-database'
import { ExecutionStatsReader } from './execution-stats-reader'
let directory: string
let workerPath: string
beforeAll(async () => {
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-statistics-reader-'))
workerPath = process.env.GOODBUDDY_STATS_WORKER_PATH ?? join(directory, 'worker.cjs')
if (!process.env.GOODBUDDY_STATS_WORKER_PATH) {
await build({ entryPoints: [resolve('src/main/execution-stats-worker.ts')], outfile: workerPath,
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
}
})
afterAll(async () => { await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) })
it('reads committed WAL evidence off-thread without recovering live tasks and refreshes external writes', async () => {
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
database.initialize(directory)
const projectId = database.listProjects()[0]!.id
const conversationId = randomUUID()
const id = randomUUID()
database.createTask({ id, projectId, conversationId, title: 'Live', instructions: 'Reply', workMode: 'ask', visible: false })
database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' })
const sql = new DatabaseSync(path)
try {
const status = database.getTask(id).status
const [conversation, project] = await Promise.all([
database.getExecutionStatsAsync({ conversationId }, new Set([id]), workerPath),
database.getExecutionStatsAsync({ projectId }, new Set([id]), workerPath)
])
expect(conversation).toMatchObject({ requestCount: 1, activeRequestCount: 1, incompleteRequestCount: 0 })
expect(project.requestCount).toBe(1)
expect(database.getTask(id).status).toBe(status)
database.updateTaskStatus(id, 'completed')
expect(await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).toMatchObject({ requestCount: 1, activeRequestCount: 0 })
sql.exec('BEGIN IMMEDIATE')
sql.prepare('DELETE FROM tasks WHERE id = ?').run(id)
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1)
sql.exec('ROLLBACK')
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1)
sql.prepare('DELETE FROM tasks WHERE id = ?').run(id)
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(0)
// A returned result must not leave a read transaction pinning the WAL.
expect(sql.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()?.busy).toBe(0)
const pending = database.getExecutionStatsAsync({ projectId }, new Set(), workerPath)
const rejection = expect(pending).rejects.toThrow('closed')
database.close()
await rejection
} finally { sql.close(); database.close() }
})
it('rejects worker startup errors and permits the next read to retry', async () => {
const reader = new ExecutionStatsReader(join(directory, 'missing.sqlite'), workerPath)
try {
for (let attempt = 0; attempt < 2; attempt++) {
await expect(reader.read({ projectId: randomUUID() }, new Set())).rejects.toThrow()
}
} finally { reader.close() }
})
@@ -0,0 +1,52 @@
import { Worker } from 'node:worker_threads'
import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts'
export class ExecutionStatsReader {
private worker?: Worker
private nextId = 0
private readonly pending = new Map<number, {
resolve: (value: ExecutionStats) => void
reject: (error: Error) => void
}>()
constructor(private readonly databasePath: string, private readonly workerPath: string) {}
read(scope: ExecutionStatsInput, activeRequestIds: ReadonlySet<string>): Promise<ExecutionStats> {
if (!this.worker) {
const worker = new Worker(this.workerPath, { workerData: { databasePath: this.databasePath } })
this.worker = worker
worker.on('message', (message: { id: number; result?: ExecutionStats; error?: string }) => {
const request = this.pending.get(message.id)
if (!request) return
this.pending.delete(message.id)
if (message.result) request.resolve(message.result)
else request.reject(new Error(message.error ?? 'Statistics query failed'))
})
const failed = (error: Error): void => {
if (this.worker !== worker) return
this.worker = undefined
for (const request of this.pending.values()) request.reject(error)
this.pending.clear()
void worker.terminate()
}
worker.once('error', failed)
worker.once('exit', (code) => failed(new Error(`Statistics worker exited (${code})`)))
worker.unref()
}
// Bound outstanding work when a window repeatedly changes scope during a slow read.
if (this.pending.size >= 16) return Promise.reject(new Error('Statistics reader is busy'))
const id = ++this.nextId
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.worker!.postMessage({ id, scope, activeRequestIds: [...activeRequestIds] })
})
}
close(): void {
const worker = this.worker
this.worker = undefined
for (const request of this.pending.values()) request.reject(new Error('Statistics reader closed'))
this.pending.clear()
if (worker) void worker.terminate()
}
}
+17
View File
@@ -0,0 +1,17 @@
import { parentPort, workerData } from 'node:worker_threads'
import type { ExecutionStatsInput } from '../shared/assistant-contracts'
import { AssistantDatabase } from './assistant/assistant-database'
const database = new AssistantDatabase((workerData as { databasePath: string }).databasePath)
database.openReadOnly()
parentPort!.on('message', (message: {
id: number; scope: ExecutionStatsInput; activeRequestIds: string[]
}) => {
try {
const result = database.getExecutionStats(message.scope, new Set(message.activeRequestIds))
parentPort!.postMessage({ id: message.id, result })
} catch (error) {
parentPort!.postMessage({ id: message.id, error: error instanceof Error ? error.message : String(error) })
}
})
parentPort!.on('close', () => database.close())
+5 -4
View File
@@ -1324,6 +1324,7 @@ describe('registerIpcHandlers model download source routing', () => {
vi.mock('electron', () => ({
nativeImage: { createFromBuffer: (buffer: Buffer) => ({ isEmpty: () => false, toPNG: () => buffer }) },
app: {
getAppPath: vi.fn(() => process.cwd()),
getName: vi.fn(() => 'GoodBuddy'),
getVersion: vi.fn(() => '0.1.0')
},
@@ -4382,7 +4383,7 @@ describe('registerIpcHandlers token usage', () => {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getExecutionStats: vi.fn(() => summary)
getExecutionStatsAsync: vi.fn(async () => summary)
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
@@ -4400,13 +4401,13 @@ describe('registerIpcHandlers token usage', () => {
const event = { sender: webContents, senderFrame: webContents.mainFrame }
const id = '00000000-0000-4000-8000-000000000301'
for (const scope of [{ conversationId: id }, { projectId: id }]) {
expect(handler(event, scope)).toBe(summary)
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope, new Set())
await expect(handler(event, scope)).resolves.toEqual(summary)
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenLastCalledWith(scope, new Set(), expect.stringContaining('execution-stats-worker.js'))
}
expect(() => handler(event, {})).toThrow()
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenCalledTimes(2)
} finally {
await dispose()
}
+3 -2
View File
@@ -7504,9 +7504,10 @@ export function registerIpcHandlers(
})
registerHandler(ipcChannels.tasksExecutionStats, (event, input: unknown) => {
assertTrustedSender(event, window)
return assistantDatabase.getExecutionStats(
return assistantDatabase.getExecutionStatsAsync(
executionStatsInputSchema.parse(input),
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id))
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id)),
join(app.getAppPath(), 'out/main/execution-stats-worker.js')
)
})
registerHandler(ipcChannels.tasksSetStatus, (event, input: unknown) => {
+1 -5
View File
@@ -4372,11 +4372,7 @@ describe("App", () => {
const dialog = screen.getByRole("alertdialog", {
name: "确认永久删除对话 新对话",
});
expect(dialog).toHaveTextContent("将永久删除此会话的全部内容");
expect(dialog).toHaveTextContent(
"如果此会话有正在运行的任务,也会同时停止",
);
expect(dialog).toHaveTextContent("此操作不可恢复");
expect(dialog).not.toHaveTextContent("将永久删除此会话的全部内容");
const cancel = screen.getByRole("button", {
name: "取消删除对话 新对话",
});
-1
View File
@@ -9373,7 +9373,6 @@ function App(): React.JSX.Element {
}
disabled={deletingConversationId === conversation.id}
icon={<Trash2 aria-hidden="true" size={14} />}
message={t("conversation.delete.message")}
onCancel={() => setConfirmingConversationId("")}
onConfirm={() =>
void deleteConversation(conversation.id)
+29
View File
@@ -10,6 +10,7 @@ import {
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
useEffect,
useState,
type ReactNode
} from 'react'
@@ -86,6 +87,34 @@ function ControlledShell({
}
describe('WorkbarShell', () => {
it('preserves task state and latest props while keeping hidden terminal and browser effects live', () => {
const stopped = vi.fn()
function Panel({ id, value }: { id: string; value: string }): ReactNode {
const [count, setCount] = useState(0)
useEffect(() => () => stopped(id), [id])
return <button onClick={() => setCount(count + 1)}>{id}:{value}:{count}</button>
}
const props = {
instances: [...DEFAULT_WORKBAR_INSTANCES, terminalOne],
onActiveInstanceChange: vi.fn(), onCloseInstance: vi.fn(), onCreateInstance: vi.fn(),
onResolveTerminalTarget: (): WorkbarTargetRef => ({ type: 'local' })
}
const panel = (value: string) => (instance: WorkbarTabInstance): ReactNode =>
<Panel id={instance.appId} value={value} />
const { rerender } = render(<WorkbarShell {...props}
activeInstanceId={DEFAULT_WORKBAR_INSTANCES[0]!.id} renderPanel={panel('old')} />)
fireEvent.click(screen.getByRole('button', { name: 'tasks:old:0' }))
rerender(<WorkbarShell {...props} activeInstanceId={terminalOne.id} renderPanel={panel('new')} />)
expect(stopped).toHaveBeenCalledWith('tasks')
expect(stopped).not.toHaveBeenCalledWith('terminal')
expect(stopped).not.toHaveBeenCalledWith('browser')
rerender(<WorkbarShell {...props}
activeInstanceId={DEFAULT_WORKBAR_INSTANCES[0]!.id} renderPanel={panel('latest')} />)
expect(screen.getByRole('button', { name: 'tasks:latest:1' })).toBeVisible()
expect(stopped).not.toHaveBeenCalledWith('terminal')
expect(stopped).not.toHaveBeenCalledWith('browser')
})
it('shows overflow controls, scrolls without switching tabs, and hides them after widening', () => {
let resize = (): void => {}
vi.stubGlobal('ResizeObserver', class {
+15 -1
View File
@@ -10,6 +10,7 @@ import {
type LucideIcon
} from 'lucide-react'
import {
Activity,
useCallback,
useEffect,
useId,
@@ -77,6 +78,15 @@ export type WorkbarShellProps = {
className?: string
}
type WorkbarPanelProps = {
instance: WorkbarTabInstance
renderPanel: (instance: WorkbarTabInstance) => ReactNode
}
function WorkbarPanel({ instance, renderPanel }: WorkbarPanelProps): React.JSX.Element {
return <>{renderPanel(instance)}</>
}
function joinClassNames(...values: (string | false | undefined)[]): string {
return values.filter(Boolean).join(' ')
}
@@ -525,7 +535,11 @@ export function WorkbarShell({
role="tabpanel"
tabIndex={0}
>
{renderPanel(instance)}
{instance.appId === 'tasks' ? (
<Activity mode={!catalogOpen && instance.id === activeInstanceId ? 'visible' : 'hidden'}>
<WorkbarPanel instance={instance} renderPanel={renderPanel} />
</Activity>
) : <WorkbarPanel instance={instance} renderPanel={renderPanel} />}
</div>
))}
@@ -49,6 +49,16 @@ afterEach(() => {
})
describe('useExecutionStats', () => {
it('retains snapshot references for identical cached polling results', async () => {
getExecutionStats.mockImplementation(async () => stats(10))
const { result } = renderStats()
await act(async () => {})
const before = result.current
await act(async () => { vi.advanceTimersByTime(5_000) })
expect(result.current.conversation).toBe(before.conversation)
expect(result.current.project).toBe(before.project)
expect(getExecutionStats).toHaveBeenCalledTimes(4)
})
it.each(['project', 'conversation'] as const)(
'clears the previous %s selection and ignores its late polling result', async (scope) => {
const { result, rerender } = renderStats()
+13 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { startTransition, useEffect, useState } from 'react'
import type { ExecutionStats } from '../../shared/assistant-contracts'
// Keep timing evidence in Main; never extrapolate a stopped or disconnected run.
@@ -26,11 +26,18 @@ export function useExecutionStats(
window.goodbuddy.tasks.getExecutionStats({ projectId })
])
pending = false
if (!cancelled) setSnapshot({
conversationId, projectId,
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
project: project.status === 'fulfilled' ? project.value : undefined
})
if (!cancelled) startTransition(() => setSnapshot((current) => {
const next = {
conversationId, projectId,
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
project: project.status === 'fulfilled' ? project.value : undefined
}
// IPC clones cached snapshots. Preserve references when their contents are unchanged
// so idle polling does not rerender App and rebuild task duration maps.
return current.conversationId === conversationId && current.projectId === projectId &&
JSON.stringify(current.conversation) === JSON.stringify(next.conversation) &&
JSON.stringify(current.project) === JSON.stringify(next.project) ? current : next
}))
}
void refresh()
const interval = window.setInterval(() => { void refresh() }, 5_000)