release: prepare Desktop 0.13.10
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user