fix: stop subagent history from exhausting disk space
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Repeated full subagent progress snapshots could grow the desktop database to many gigabytes during ordinary long-running tasks. Local and managed SSH writes now retain lossless progress changes while preserving conversation snapshots, event identities, remote replay deduplication and transaction rollback. Upgrade existing history in bounded worker-thread batches, show progress and retry controls, and reclaim freed disk space after conversion. Schema 34 cannot be opened by older clients; a pre-upgrade backup is required for client rollback. Document reusable database migration guidance and the verified large-database, portable, local Runtime and Linux Host evidence. Prepare Desktop 0.13.1 with the approved bilingual notes and aligned feature lists. Agent remains 0.11.24. Final candidate validation and production builds will run on main CI, as requested, without additional local release testing. Release note: 修复长任务和子任务造成的数据库异常膨胀;升级时自动整理旧记录并回收空间,保留聊天、执行详情和结果,并支持退出后继续处理。
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { app, ipcMain, type BrowserWindow } from 'electron'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import {
|
||||
assistantStorageActionSchema,
|
||||
assistantStorageProgressSchema,
|
||||
type AssistantStorageProgress
|
||||
} from '../shared/assistant-storage-contracts'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import { assertTrustedSender } from './trusted-ipc-sender'
|
||||
import { loadMainWindow } from './window'
|
||||
|
||||
export async function prepareAssistantStorage(
|
||||
window: BrowserWindow,
|
||||
databasePath: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const file = await stat(databasePath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return undefined
|
||||
throw error
|
||||
})
|
||||
if (!file) return
|
||||
let progress: AssistantStorageProgress = {
|
||||
stage: 'scanning', processed: 0, total: 0, bytesBefore: file.size
|
||||
}
|
||||
let shown = false
|
||||
let retry: (() => void) | undefined
|
||||
ipcMain.handle(ipcChannels.storageUpgradeProgress, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return progress
|
||||
})
|
||||
ipcMain.handle(ipcChannels.storageUpgradeAction, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const action = assistantStorageActionSchema.parse(input)
|
||||
if (action === 'quit') app.quit()
|
||||
else if (progress.stage === 'failed') retry?.()
|
||||
})
|
||||
const publish = (next: AssistantStorageProgress): void => {
|
||||
progress = assistantStorageProgressSchema.parse(next)
|
||||
if (window.isDestroyed()) return
|
||||
if (!shown) {
|
||||
shown = true
|
||||
loadMainWindow(window, true)
|
||||
}
|
||||
window.setProgressBar(next.stage === 'failed' ? -1 :
|
||||
next.total > 0 && next.stage === 'converting'
|
||||
? next.processed / next.total : 2)
|
||||
}
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
await runStorageWorker(databasePath, signal, publish)
|
||||
return
|
||||
} catch (error) {
|
||||
if (signal.aborted) return
|
||||
publish({
|
||||
...progress, stage: 'failed',
|
||||
error: error instanceof Error ? error.message : '历史执行记录优化失败'
|
||||
})
|
||||
await new Promise<void>((resolve) => {
|
||||
const finish = (): void => {
|
||||
signal.removeEventListener('abort', finish)
|
||||
retry = undefined
|
||||
resolve()
|
||||
}
|
||||
retry = finish
|
||||
signal.addEventListener('abort', finish, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ipcMain.removeHandler(ipcChannels.storageUpgradeProgress)
|
||||
ipcMain.removeHandler(ipcChannels.storageUpgradeAction)
|
||||
if (!window.isDestroyed()) window.setProgressBar(-1)
|
||||
}
|
||||
}
|
||||
|
||||
function runStorageWorker(
|
||||
databasePath: string,
|
||||
signal: AbortSignal,
|
||||
publish: (progress: AssistantStorageProgress) => void
|
||||
): Promise<void> {
|
||||
const cancellation = new SharedArrayBuffer(4)
|
||||
const cancel = (): void => {
|
||||
Atomics.store(new Int32Array(cancellation), 0, 1)
|
||||
}
|
||||
if (signal.aborted) return Promise.resolve()
|
||||
signal.addEventListener('abort', cancel, { once: true })
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const worker = new Worker(join(
|
||||
dirname(fileURLToPath(import.meta.url)), 'assistant-storage-worker.js'
|
||||
), { workerData: { databasePath, cancellation } })
|
||||
let failure: Error | undefined
|
||||
let done = false
|
||||
worker.on('message', (message: {
|
||||
progress?: AssistantStorageProgress
|
||||
error?: string
|
||||
cancelled?: boolean
|
||||
done?: boolean
|
||||
}) => {
|
||||
if (message.progress) publish(message.progress)
|
||||
if (message.error && !message.cancelled) failure = new Error(message.error)
|
||||
if (message.done || message.cancelled) done = true
|
||||
})
|
||||
worker.once('error', () => {
|
||||
failure = new Error('无法启动历史执行记录优化,请重启 GoodBuddy 后重试。')
|
||||
})
|
||||
worker.once('exit', (code) => {
|
||||
signal.removeEventListener('abort', cancel)
|
||||
if (failure) reject(failure)
|
||||
else if (done && code === 0) resolve()
|
||||
else reject(new Error('历史执行记录优化意外结束,请重试。'))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { upgradeAssistantStorage } from './assistant/assistant-storage-upgrade'
|
||||
|
||||
const input = workerData as {
|
||||
databasePath: string
|
||||
cancellation: SharedArrayBuffer
|
||||
}
|
||||
const cancellation = new Int32Array(input.cancellation)
|
||||
try {
|
||||
upgradeAssistantStorage(
|
||||
input.databasePath,
|
||||
(progress) => parentPort?.postMessage({ progress }),
|
||||
() => Atomics.load(cancellation, 0) !== 0
|
||||
)
|
||||
parentPort?.postMessage({ done: true })
|
||||
} catch (error) {
|
||||
parentPort?.postMessage({
|
||||
error: error instanceof Error && /full|space/iu.test(error.message)
|
||||
? '磁盘空间不足,无法完成空间回收。请释放一些空间后重试。'
|
||||
: '历史执行记录优化未完成。请重试;若仍失败,请备份数据并联系支持。',
|
||||
cancelled: error instanceof Error && error.name === 'AbortError'
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
conversationSnapshotSchema,
|
||||
isUntouchedBuiltInDefaultProject
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import {
|
||||
AssistantDatabase,
|
||||
ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
} from './assistant-database'
|
||||
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
@@ -641,7 +644,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 33', async () => {
|
||||
it('migrates existing databases to the current schema version', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -670,7 +673,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(33)
|
||||
).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -840,7 +843,7 @@ describe('AssistantDatabase', () => {
|
||||
enableForeignKeyConstraints: true
|
||||
})
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 33
|
||||
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
})
|
||||
expect(
|
||||
inspected.prepare('SELECT * FROM projects ORDER BY rowid').all()
|
||||
@@ -938,7 +941,7 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 33
|
||||
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
})
|
||||
expect(
|
||||
inspected
|
||||
@@ -1015,7 +1018,7 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 33
|
||||
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
})
|
||||
expect(
|
||||
inspected
|
||||
@@ -1265,7 +1268,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(33)
|
||||
).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -1403,7 +1406,7 @@ describe('AssistantDatabase', () => {
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
inspected.prepare('PRAGMA user_version').get()
|
||||
).toEqual({ user_version: 33 })
|
||||
).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION })
|
||||
expect(
|
||||
inspected
|
||||
.prepare(
|
||||
@@ -3102,7 +3105,7 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 33
|
||||
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
})
|
||||
expect(
|
||||
inspected.prepare('PRAGMA table_info(task_events)').all()
|
||||
|
||||
@@ -95,8 +95,9 @@ import {
|
||||
setMagicNoteChecklistCompletion
|
||||
} from '../magic-notes/rich-content'
|
||||
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
|
||||
import { SubagentProgressStorage } from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 34
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -1750,6 +1751,7 @@ function deleteProjectRecords(
|
||||
|
||||
export class AssistantDatabase {
|
||||
private database?: DatabaseSync
|
||||
private subagentProgress?: SubagentProgressStorage
|
||||
private channelEventWrites = 0
|
||||
private channelOutboxWrites = 0
|
||||
|
||||
@@ -1780,6 +1782,7 @@ export class AssistantDatabase {
|
||||
`)
|
||||
this.migrate(database)
|
||||
this.database = database
|
||||
this.subagentProgress = new SubagentProgressStorage(database)
|
||||
this.channelEventWrites = (
|
||||
database
|
||||
.prepare('SELECT COUNT(*) AS count FROM channel_events')
|
||||
@@ -2015,6 +2018,7 @@ export class AssistantDatabase {
|
||||
close(): void {
|
||||
this.database?.close()
|
||||
this.database = undefined
|
||||
this.subagentProgress = undefined
|
||||
}
|
||||
|
||||
clearAssistantData(): void {
|
||||
@@ -4931,7 +4935,9 @@ export class AssistantDatabase {
|
||||
kind: string,
|
||||
payload: unknown
|
||||
): void {
|
||||
this.requireDatabase()
|
||||
const database = this.requireDatabase()
|
||||
const payloadJson = this.subagentProgress!.serialize(taskId, kind, payload)
|
||||
const result = database
|
||||
.prepare(
|
||||
`INSERT INTO task_events
|
||||
(task_id, run_id, kind, payload_json, created_at)
|
||||
@@ -4940,9 +4946,12 @@ export class AssistantDatabase {
|
||||
.run(
|
||||
taskId,
|
||||
kind.slice(0, 64),
|
||||
JSON.stringify(payload),
|
||||
payloadJson,
|
||||
new Date().toISOString()
|
||||
)
|
||||
this.subagentProgress!.inserted(
|
||||
taskId, kind, Number(result.lastInsertRowid), payloadJson
|
||||
)
|
||||
}
|
||||
|
||||
appendRemoteTaskEventOnce(input: RemoteTaskEventInput): boolean {
|
||||
@@ -5335,6 +5344,9 @@ export class AssistantDatabase {
|
||||
database: DatabaseSync,
|
||||
event: ValidatedRemoteTaskEvent
|
||||
): boolean {
|
||||
const payloadJson = this.subagentProgress!.serialize(
|
||||
event.taskId, event.kind, JSON.parse(event.payloadJson)
|
||||
)
|
||||
const result = database
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO task_events
|
||||
@@ -5346,7 +5358,7 @@ export class AssistantDatabase {
|
||||
.run(
|
||||
event.taskId,
|
||||
event.kind,
|
||||
event.payloadJson,
|
||||
payloadJson,
|
||||
new Date().toISOString(),
|
||||
event.bindingId,
|
||||
event.operationId,
|
||||
@@ -5354,11 +5366,14 @@ export class AssistantDatabase {
|
||||
event.eventIndex
|
||||
)
|
||||
if (result.changes === 1) {
|
||||
this.subagentProgress!.inserted(
|
||||
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
|
||||
)
|
||||
return true
|
||||
}
|
||||
const existing = database
|
||||
.prepare(
|
||||
`SELECT task_id, kind, payload_json
|
||||
`SELECT id, task_id, kind, payload_json
|
||||
FROM task_events
|
||||
WHERE remote_binding_id = ?
|
||||
AND remote_operation_id = ?
|
||||
@@ -5372,6 +5387,7 @@ export class AssistantDatabase {
|
||||
event.eventIndex
|
||||
) as
|
||||
| {
|
||||
id: number
|
||||
task_id: string
|
||||
kind: string
|
||||
payload_json: string
|
||||
@@ -5380,7 +5396,10 @@ export class AssistantDatabase {
|
||||
if (
|
||||
existing?.task_id === event.taskId &&
|
||||
existing.kind === event.kind &&
|
||||
existing.payload_json === event.payloadJson
|
||||
(existing.payload_json === event.payloadJson ||
|
||||
(event.kind === 'subagent' && this.subagentProgress!.matches(
|
||||
event.taskId, existing.id, JSON.parse(event.payloadJson)
|
||||
)))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -9653,6 +9672,15 @@ export class AssistantDatabase {
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
if (version.user_version < 34) {
|
||||
database.exec(`
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE INDEX IF NOT EXISTS task_events_subagent_idx
|
||||
ON task_events(task_id, id) WHERE kind = 'subagent';
|
||||
PRAGMA user_version = 34;
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import type { SubagentEvent } from '../../shared/contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { upgradeAssistantStorage } from './assistant-storage-upgrade'
|
||||
import {
|
||||
compactSubagentPayload,
|
||||
restoreSubagentPayload
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
const directories: string[] = []
|
||||
const databases: AssistantDatabase[] = []
|
||||
afterEach(async () => {
|
||||
for (const database of databases.splice(0)) database.close()
|
||||
await Promise.all(directories.splice(0).map(
|
||||
(path) => rm(path, { recursive: true, force: true })
|
||||
))
|
||||
})
|
||||
|
||||
async function fixture(): Promise<{
|
||||
path: string; database: AssistantDatabase; taskId: string
|
||||
}> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-storage-upgrade-'))
|
||||
directories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
databases.push(database)
|
||||
database.initialize(directory)
|
||||
const taskId = randomUUID()
|
||||
database.createTask({
|
||||
id: taskId, title: 'Storage regression', instructions: 'Synthetic fixture',
|
||||
workMode: 'execute'
|
||||
})
|
||||
return { path, database, taskId }
|
||||
}
|
||||
|
||||
function subagent(requestId: string): SubagentEvent {
|
||||
return {
|
||||
requestId, type: 'subagent', childTaskId: randomUUID(),
|
||||
expertId: randomUUID(), expertName: 'general', routingMode: 'native',
|
||||
runtimeCallId: 'test-call', state: 'running', progress: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('subagent progress storage', () => {
|
||||
it('roundtrips every snapshot including corrections, reordered blocks and empty progress', () => {
|
||||
const event = subagent(randomUUID())
|
||||
const textId = randomUUID()
|
||||
const toolId = randomUUID()
|
||||
const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' }
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: toolId, type: 'tool', tool: { name: 'read', state: 'completed', summary: 'Read', output: 'seed' }
|
||||
}
|
||||
const encoder = new Map<string, ConversationMessageBlock[]>()
|
||||
const decoder = new Map<string, ConversationMessageBlock[]>()
|
||||
for (const progress of [
|
||||
[], [text], [text, tool],
|
||||
[{ ...text, content: 'seed tail' }, tool],
|
||||
[{ ...text, content: 'corrected' }, tool],
|
||||
[tool, text], [], [text]
|
||||
]) {
|
||||
const full = { ...event, progress }
|
||||
const compact = compactSubagentPayload(full, encoder)
|
||||
restoreSubagentPayload(full, encoder)
|
||||
expect(restoreSubagentPayload(compact, decoder)).toEqual(full)
|
||||
}
|
||||
})
|
||||
|
||||
it('stores small changes without repeating large tool outputs, including across reopen', async () => {
|
||||
const fixtureData = await fixture()
|
||||
let database = fixtureData.database
|
||||
const event = subagent(fixtureData.taskId)
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: randomUUID(), type: 'tool',
|
||||
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(200_000) }
|
||||
}
|
||||
const textId = randomUUID()
|
||||
try {
|
||||
for (let index = 0; index < 200; index++) {
|
||||
event.progress = [tool, { id: textId, type: 'text', content: `seed${'中'.repeat(index)}` }]
|
||||
database.appendTaskEvent(fixtureData.taskId, 'subagent', event)
|
||||
if (index === 99) {
|
||||
database.close()
|
||||
database = new AssistantDatabase(fixtureData.path)
|
||||
database.initialize(tmpdir())
|
||||
}
|
||||
}
|
||||
database.close()
|
||||
const inspection = new DatabaseSync(fixtureData.path)
|
||||
try {
|
||||
const rows = inspection.prepare(
|
||||
`SELECT payload_json FROM task_events WHERE kind = 'subagent' ORDER BY id`
|
||||
).all() as Array<{ payload_json: string }>
|
||||
expect(rows).toHaveLength(200)
|
||||
expect(rows.reduce((sum, row) => sum + row.payload_json.length, 0)).toBeLessThan(400_000)
|
||||
const state = new Map<string, ConversationMessageBlock[]>()
|
||||
let restored: unknown
|
||||
for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state)
|
||||
expect(restored).toEqual(event)
|
||||
} finally { inspection.close() }
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => {
|
||||
const { database, taskId, path } = await fixture()
|
||||
const event = subagent(taskId)
|
||||
const textId = randomUUID()
|
||||
const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
|
||||
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
|
||||
const second = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed tail' }] }
|
||||
try {
|
||||
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true)
|
||||
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true)
|
||||
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false)
|
||||
expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts')
|
||||
} finally { database.close() }
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize(tmpdir())
|
||||
try {
|
||||
expect(reopened.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(false)
|
||||
expect(reopened.getHighestCommittedRemoteTaskEventSequence('binding', 'operation')).toBe('2')
|
||||
} finally { reopened.close() }
|
||||
})
|
||||
|
||||
it('rebuilds progress after a remote batch rolls back', async () => {
|
||||
const { database, taskId } = await fixture()
|
||||
const event = subagent(taskId)
|
||||
const textId = randomUUID()
|
||||
const base = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
|
||||
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
|
||||
try {
|
||||
database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '2', payload: first })
|
||||
expect(() => database.appendRemoteTaskEventsBatch([
|
||||
{ ...base, semanticSequence: '1', payload: { ...first, progress: [] } },
|
||||
{ ...base, semanticSequence: '2', payload: { ...first, output: 'conflict' } }
|
||||
])).toThrow('conflicts')
|
||||
expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(true)
|
||||
expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(false)
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('keeps full remote conversation progress atomic with compact events', async () => {
|
||||
const { database, path } = await fixture()
|
||||
const hostId = randomUUID()
|
||||
const project = database.createSshProject({
|
||||
project: {
|
||||
name: 'Remote regression', description: '', rootPath: '/tmp/goodbuddy-storage-test',
|
||||
defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' }
|
||||
},
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/tmp/goodbuddy-storage-test' },
|
||||
assertCurrent: () => undefined
|
||||
})
|
||||
const conversationId = randomUUID()
|
||||
const userMessageId = randomUUID()
|
||||
const assistantMessageId = randomUUID()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId: project.id, title: 'Atomic regression', updatedAt: Date.now() },
|
||||
messages: []
|
||||
}])
|
||||
const remoteTaskId = randomUUID()
|
||||
database.createTask({
|
||||
id: remoteTaskId, projectId: project.id, conversationId,
|
||||
title: 'Atomic remote', instructions: 'Synthetic', workMode: 'execute',
|
||||
remoteRecovery: {
|
||||
recoverable: true, currentUserMessageId: userMessageId,
|
||||
currentAssistantMessageId: assistantMessageId
|
||||
}
|
||||
})
|
||||
const event = subagent(remoteTaskId)
|
||||
const textId = randomUUID()
|
||||
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
|
||||
const second = { ...event, state: 'completed' as const, output: 'Complete', progress: [
|
||||
{ id: textId, type: 'text' as const, content: 'seed tail' }
|
||||
] }
|
||||
const owner = {
|
||||
taskId: remoteTaskId, conversationId, assistantMessageId,
|
||||
bindingId: 'atomic-binding', operationId: 'atomic-operation', eventIndex: 0
|
||||
}
|
||||
const inspection = new DatabaseSync(path)
|
||||
try {
|
||||
database.appendRemoteConversationTaskEventOnce({ ...owner, semanticSequence: '1', event: first })
|
||||
inspection.exec(`CREATE TRIGGER reject_message BEFORE UPDATE ON messages
|
||||
BEGIN SELECT RAISE(ABORT, 'fixture message write failed'); END;`)
|
||||
expect(() => database.appendRemoteConversationTaskEventOnce({
|
||||
...owner, semanticSequence: '2', event: second
|
||||
})).toThrow('fixture message write failed')
|
||||
expect(database.getHighestCommittedRemoteTaskEventSequence('atomic-binding', 'atomic-operation')).toBe('1')
|
||||
inspection.exec('DROP TRIGGER reject_message')
|
||||
expect(database.appendRemoteConversationTaskEventOnce({
|
||||
...owner, semanticSequence: '2', event: second
|
||||
})).toBe(true)
|
||||
expect(database.appendRemoteConversationTaskEventOnce({
|
||||
...owner, semanticSequence: '2', event: second
|
||||
})).toBe(false)
|
||||
const message = database.getConversation(conversationId).messages.find((row) => row.id === assistantMessageId)
|
||||
expect(message?.subagents?.[0]).toMatchObject({
|
||||
state: 'completed', progress: second.progress, output: 'Complete'
|
||||
})
|
||||
expect(inspection.prepare(
|
||||
`SELECT COUNT(*) AS count FROM task_events WHERE task_id = ? AND kind = 'subagent'`
|
||||
).get(remoteTaskId)).toEqual({ count: 2 })
|
||||
} finally { inspection.close(); database.close() }
|
||||
})
|
||||
|
||||
it('converts legacy rows in batches, resumes after cancellation and actually shrinks the file', async () => {
|
||||
const { database, taskId, path } = await fixture()
|
||||
database.close()
|
||||
const event = subagent(taskId)
|
||||
const tool: ConversationMessageBlock = {
|
||||
id: randomUUID(), type: 'tool',
|
||||
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(300_000) }
|
||||
}
|
||||
const textId = randomUUID()
|
||||
const legacy = new DatabaseSync(path)
|
||||
const expected: SubagentEvent[] = []
|
||||
try {
|
||||
legacy.exec('PRAGMA user_version = 33; BEGIN')
|
||||
const insert = legacy.prepare(
|
||||
`INSERT INTO task_events(task_id, kind, payload_json, created_at)
|
||||
VALUES (?, 'subagent', ?, ?)`
|
||||
)
|
||||
for (let index = 0; index < 150; index++) {
|
||||
const full = {
|
||||
...event,
|
||||
progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }]
|
||||
}
|
||||
expected.push(full)
|
||||
insert.run(taskId, JSON.stringify(full), '2026-09-13T00:00:00Z')
|
||||
}
|
||||
legacy.exec('COMMIT')
|
||||
} finally { legacy.close() }
|
||||
const before = (await stat(path)).size
|
||||
expect(before).toBeGreaterThan(40_000_000)
|
||||
let cancelled = false
|
||||
expect(() => upgradeAssistantStorage(path, (progress) => {
|
||||
if (progress.processed >= 32) cancelled = true
|
||||
}, () => cancelled)).toThrow('cancelled')
|
||||
upgradeAssistantStorage(path, () => undefined)
|
||||
const after = (await stat(path)).size
|
||||
expect(after).toBeLessThan(before / 20)
|
||||
const inspected = new DatabaseSync(path)
|
||||
try {
|
||||
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
const rows = inspected.prepare(
|
||||
`SELECT payload_json, created_at FROM task_events WHERE kind = 'subagent' ORDER BY id`
|
||||
).all() as Array<{ payload_json: string; created_at: string }>
|
||||
expect(rows).toHaveLength(expected.length)
|
||||
const blocks = new Map<string, ConversationMessageBlock[]>()
|
||||
rows.forEach((row, index) => {
|
||||
expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index])
|
||||
expect(row.created_at).toBe('2026-09-13T00:00:00Z')
|
||||
})
|
||||
} finally { inspected.close() }
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize(tmpdir())
|
||||
reopened.close()
|
||||
const messages: unknown[] = []
|
||||
upgradeAssistantStorage(path, (value) => messages.push(value))
|
||||
expect(messages).toEqual([])
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
import {
|
||||
compactSubagentPayload,
|
||||
restoreSubagentPayload
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export function upgradeAssistantStorage(
|
||||
databasePath: string,
|
||||
onProgress: (progress: AssistantStorageProgress) => void,
|
||||
isCancelled: () => boolean = () => false
|
||||
): void {
|
||||
const database = new DatabaseSync(databasePath, { timeout: 5_000 })
|
||||
const checkCancelled = (): void => {
|
||||
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
}
|
||||
const bytesBefore = statSync(databasePath).size
|
||||
try {
|
||||
const version = database.prepare('PRAGMA user_version').get() as {
|
||||
user_version: number
|
||||
}
|
||||
if (version.user_version < 1 || version.user_version >= 34) return
|
||||
checkCancelled()
|
||||
const progress: AssistantStorageProgress = {
|
||||
stage: 'scanning', processed: 0, total: 0, bytesBefore
|
||||
}
|
||||
onProgress({ ...progress })
|
||||
database.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
PRAGMA secure_delete = OFF;
|
||||
PRAGMA cache_size = -65536;
|
||||
CREATE INDEX IF NOT EXISTS task_events_subagent_idx
|
||||
ON task_events(task_id, id) WHERE kind = 'subagent';
|
||||
`)
|
||||
const tasks = database.prepare(
|
||||
`SELECT task_id, COUNT(*) AS count FROM task_events
|
||||
WHERE kind = 'subagent' GROUP BY task_id`
|
||||
).all() as Array<{ task_id: string; count: number }>
|
||||
progress.total = tasks.reduce((sum, task) => sum + task.count, 0)
|
||||
progress.stage = 'converting'
|
||||
const select = database.prepare(
|
||||
`SELECT id, payload_json FROM task_events
|
||||
WHERE task_id = ? AND kind = 'subagent' AND id > ?
|
||||
ORDER BY id LIMIT 32`
|
||||
)
|
||||
const update = database.prepare(
|
||||
'UPDATE task_events SET payload_json = ? WHERE id = ?'
|
||||
)
|
||||
for (const task of tasks) {
|
||||
const blocks = new Map<string, ConversationMessageBlock[]>()
|
||||
let afterId = 0
|
||||
for (;;) {
|
||||
checkCancelled()
|
||||
const rows = select.all(task.task_id, afterId) as Array<{
|
||||
id: number; payload_json: string
|
||||
}>
|
||||
if (!rows.length) break
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const payload: unknown = JSON.parse(row.payload_json)
|
||||
const compact = compactSubagentPayload(payload, blocks)
|
||||
// Replay mixed old/new rows on retry. Never delete events or provenance.
|
||||
restoreSubagentPayload(payload, blocks)
|
||||
if (compact !== payload) {
|
||||
update.run(JSON.stringify(compact), row.id)
|
||||
}
|
||||
afterId = row.id
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
progress.processed += rows.length
|
||||
onProgress({ ...progress })
|
||||
}
|
||||
database.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
}
|
||||
checkCancelled()
|
||||
const free = database.prepare('PRAGMA freelist_count').get() as {
|
||||
freelist_count: number
|
||||
}
|
||||
if (free.freelist_count > 0) {
|
||||
progress.stage = 'compacting'
|
||||
onProgress({ ...progress })
|
||||
// Rebuild the now-small live database, not a second copy of the bloated input.
|
||||
database.exec('PRAGMA wal_checkpoint(TRUNCATE); VACUUM;')
|
||||
database.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
}
|
||||
checkCancelled()
|
||||
onProgress({
|
||||
...progress, stage: 'complete',
|
||||
bytesAfter: statSync(databasePath).size
|
||||
})
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { z } from 'zod'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
conversationMessageBlockSchema,
|
||||
type ConversationMessageBlock
|
||||
} from '../../shared/assistant-contracts'
|
||||
|
||||
export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('append'),
|
||||
id: assistantIdSchema,
|
||||
blockType: z.enum(['text', 'reasoning']),
|
||||
delta: z.string()
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('upsert'),
|
||||
block: conversationMessageBlockSchema
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('reset'),
|
||||
blocks: z.array(conversationMessageBlockSchema)
|
||||
}).strict()
|
||||
])
|
||||
|
||||
export type SubagentProgressUpdate = z.infer<
|
||||
typeof subagentProgressUpdateSchema
|
||||
>
|
||||
|
||||
type ProgressEvent = {
|
||||
progress?: ConversationMessageBlock[]
|
||||
progressUpdates?: SubagentProgressUpdate[]
|
||||
}
|
||||
|
||||
export function applySubagentProgress(
|
||||
previous: ConversationMessageBlock[] | undefined,
|
||||
event: ProgressEvent
|
||||
): ConversationMessageBlock[] | undefined {
|
||||
let blocks = event.progress ?? previous
|
||||
if (!event.progressUpdates?.length) return blocks
|
||||
blocks = [...(blocks ?? [])]
|
||||
for (const update of event.progressUpdates) {
|
||||
if (update.type === 'reset') {
|
||||
blocks = [...update.blocks]
|
||||
continue
|
||||
}
|
||||
const id = update.type === 'upsert' ? update.block.id : update.id
|
||||
const index = blocks.findIndex((block) => block.id === id)
|
||||
const prior = blocks[index]
|
||||
const block: ConversationMessageBlock = update.type === 'upsert'
|
||||
? update.block
|
||||
: {
|
||||
id,
|
||||
type: update.blockType,
|
||||
content: (
|
||||
prior?.type === update.blockType ? prior.content : ''
|
||||
) + update.delta
|
||||
}
|
||||
if (index < 0) blocks.push(block)
|
||||
else blocks[index] = block
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function diffSubagentProgress(
|
||||
previous: readonly ConversationMessageBlock[],
|
||||
next: readonly ConversationMessageBlock[]
|
||||
): SubagentProgressUpdate[] {
|
||||
if (
|
||||
previous.length > next.length ||
|
||||
previous.some((block, index) => block.id !== next[index]?.id)
|
||||
) {
|
||||
return [{ type: 'reset', blocks: [...next] }]
|
||||
}
|
||||
const updates: SubagentProgressUpdate[] = []
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
const block = next[index]!
|
||||
const prior = previous[index]
|
||||
if (block === prior) continue
|
||||
if (
|
||||
(block.type === 'text' || block.type === 'reasoning') &&
|
||||
prior?.type === block.type &&
|
||||
block.content.startsWith(prior.content)
|
||||
) {
|
||||
const delta = block.content.slice(prior.content.length)
|
||||
if (delta) {
|
||||
updates.push({
|
||||
type: 'append', id: block.id, blockType: block.type, delta
|
||||
})
|
||||
}
|
||||
} else if (!prior || !isDeepStrictEqual(prior, block)) {
|
||||
updates.push({ type: 'upsert', block })
|
||||
}
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
type StoredSubagent = ProgressEvent & {
|
||||
childTaskId: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function asSubagent(payload: unknown): StoredSubagent | undefined {
|
||||
if (
|
||||
typeof payload !== 'object' || payload === null ||
|
||||
!('childTaskId' in payload) || typeof payload.childTaskId !== 'string'
|
||||
) return undefined
|
||||
return payload as StoredSubagent
|
||||
}
|
||||
|
||||
export function compactSubagentPayload(
|
||||
payload: unknown,
|
||||
progress: ReadonlyMap<string, ConversationMessageBlock[]>
|
||||
): unknown {
|
||||
const event = asSubagent(payload)
|
||||
if (!event || !Array.isArray(event.progress)) return payload
|
||||
const compact = { ...event }
|
||||
delete compact.progress
|
||||
compact.progressUpdates = diffSubagentProgress(
|
||||
progress.get(event.childTaskId) ?? [],
|
||||
event.progress
|
||||
)
|
||||
return compact
|
||||
}
|
||||
|
||||
export function restoreSubagentPayload(
|
||||
payload: unknown,
|
||||
progress: Map<string, ConversationMessageBlock[]>
|
||||
): unknown {
|
||||
const event = asSubagent(payload)
|
||||
if (!event) return payload
|
||||
const blocks = applySubagentProgress(
|
||||
progress.get(event.childTaskId), event
|
||||
)
|
||||
if (blocks) progress.set(event.childTaskId, blocks)
|
||||
if (!event.progressUpdates) return payload
|
||||
const restored = { ...event, progress: blocks ?? [] }
|
||||
delete restored.progressUpdates
|
||||
return restored
|
||||
}
|
||||
|
||||
type TaskProgress = {
|
||||
lastId: number
|
||||
blocks: Map<string, ConversationMessageBlock[]>
|
||||
}
|
||||
|
||||
/** The conversation keeps the display snapshot; its event history stores changes. */
|
||||
export class SubagentProgressStorage {
|
||||
private readonly tasks = new Map<string, TaskProgress>()
|
||||
|
||||
constructor(private readonly database: DatabaseSync) {}
|
||||
|
||||
serialize(taskId: string, kind: string, payload: unknown): string {
|
||||
return JSON.stringify(kind === 'subagent'
|
||||
? compactSubagentPayload(payload, this.current(taskId).blocks)
|
||||
: payload)
|
||||
}
|
||||
|
||||
inserted(taskId: string, kind: string, id: number, payloadJson: string): void {
|
||||
if (kind !== 'subagent') return
|
||||
const task = this.tasks.get(taskId)
|
||||
if (!task) return
|
||||
restoreSubagentPayload(JSON.parse(payloadJson), task.blocks)
|
||||
task.lastId = id
|
||||
}
|
||||
|
||||
matches(taskId: string, eventId: number, payload: unknown): boolean {
|
||||
const blocks = new Map<string, ConversationMessageBlock[]>()
|
||||
const rows = this.database.prepare(
|
||||
`SELECT id, payload_json FROM task_events
|
||||
WHERE task_id = ? AND kind = 'subagent' AND id <= ?
|
||||
ORDER BY id`
|
||||
).iterate(taskId, eventId)
|
||||
for (const row of rows) {
|
||||
const restored = restoreSubagentPayload(
|
||||
JSON.parse(row.payload_json as string), blocks
|
||||
)
|
||||
if (row.id === eventId) return isDeepStrictEqual(restored, payload)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private current(taskId: string): TaskProgress {
|
||||
const last = this.database.prepare(
|
||||
`SELECT id FROM task_events WHERE task_id = ? AND kind = 'subagent'
|
||||
ORDER BY id DESC LIMIT 1`
|
||||
).get(taskId) as { id: number } | undefined
|
||||
let task = this.tasks.get(taskId)
|
||||
if (task?.lastId === (last?.id ?? 0)) return task
|
||||
// A rolled-back transaction or reopened task is rebuilt from committed rows.
|
||||
task = { lastId: last?.id ?? 0, blocks: new Map() }
|
||||
const rows = this.database.prepare(
|
||||
`SELECT payload_json FROM task_events
|
||||
WHERE task_id = ? AND kind = 'subagent' ORDER BY id`
|
||||
).iterate(taskId)
|
||||
for (const row of rows) {
|
||||
restoreSubagentPayload(JSON.parse(row.payload_json as string), task.blocks)
|
||||
}
|
||||
this.tasks.delete(taskId)
|
||||
this.tasks.set(taskId, task)
|
||||
if (this.tasks.size > 16) {
|
||||
this.tasks.delete(this.tasks.keys().next().value!)
|
||||
}
|
||||
return task
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from './ipc'
|
||||
import { KnowledgeService } from './knowledge/knowledge-service'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { prepareAssistantStorage } from './assistant-storage-startup'
|
||||
import { createModelGraphExtractor } from './knowledge/model-extractor'
|
||||
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
|
||||
import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key'
|
||||
@@ -512,6 +513,9 @@ function buildTray(): Tray {
|
||||
return nextTray
|
||||
}
|
||||
|
||||
const storageUpgradeController = new AbortController()
|
||||
let storageUpgrade: Promise<void> | undefined
|
||||
|
||||
if (hasSingleInstanceLock) {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
@@ -544,6 +548,13 @@ if (hasSingleInstanceLock) {
|
||||
mainWindow = createMainWindow(() => isQuitting)
|
||||
registerDesktopNotificationActivation(mainWindow)
|
||||
tray = buildTray()
|
||||
storageUpgrade = prepareAssistantStorage(
|
||||
mainWindow,
|
||||
join(app.getPath('userData'), 'assistant.sqlite'),
|
||||
storageUpgradeController.signal
|
||||
)
|
||||
await storageUpgrade
|
||||
if (storageUpgradeController.signal.aborted) return
|
||||
const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir()
|
||||
const secureCipher = {
|
||||
isAvailable: () =>
|
||||
@@ -1478,6 +1489,7 @@ let cleanupComplete = false
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
isQuitting = true
|
||||
storageUpgradeController.abort()
|
||||
if (cleanupComplete) {
|
||||
return
|
||||
}
|
||||
@@ -1490,6 +1502,7 @@ app.on('before-quit', (event) => {
|
||||
try {
|
||||
const cleanup = settleCleanupPhases([
|
||||
[
|
||||
() => storageUpgrade,
|
||||
() => feedbackService?.dispose(),
|
||||
() => removeFeedbackIpcHandler?.(),
|
||||
() => dshExtensionInstaller?.dispose(),
|
||||
|
||||
+11
-3
@@ -105,11 +105,19 @@ export function createMainWindow(shouldQuit: () => boolean): BrowserWindow {
|
||||
return window
|
||||
}
|
||||
|
||||
export function loadMainWindow(window: BrowserWindow): void {
|
||||
export function loadMainWindow(
|
||||
window: BrowserWindow,
|
||||
storageUpgrade = false
|
||||
): void {
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
void window.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
const url = new URL(process.env.ELECTRON_RENDERER_URL)
|
||||
if (storageUpgrade) url.searchParams.set('storageUpgrade', '1')
|
||||
void window.loadURL(url.href)
|
||||
} else {
|
||||
void window.loadFile(join(currentDirectory, '../renderer/index.html'))
|
||||
void window.loadFile(
|
||||
join(currentDirectory, '../renderer/index.html'),
|
||||
storageUpgrade ? { query: { storageUpgrade: '1' } } : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,12 @@ import type {
|
||||
|
||||
|
||||
const desktopApi: DesktopApi = {
|
||||
storageUpgrade: {
|
||||
getProgress: () => ipcRenderer.invoke(ipcChannels.storageUpgradeProgress),
|
||||
act: async (action) => {
|
||||
await ipcRenderer.invoke(ipcChannels.storageUpgradeAction, action)
|
||||
}
|
||||
},
|
||||
app: {
|
||||
getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise<AppInfo>,
|
||||
show: async () => {
|
||||
|
||||
@@ -181,6 +181,10 @@ const terminalSnapshot: TerminalSnapshot = {
|
||||
};
|
||||
|
||||
const api: DesktopApi = {
|
||||
storageUpgrade: {
|
||||
getProgress: vi.fn(),
|
||||
act: vi.fn(async () => undefined)
|
||||
},
|
||||
app: {
|
||||
getInfo: vi.fn(async () => ({
|
||||
name: "GoodBuddy",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.storage-upgrade {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.storage-upgrade > .page-shell {
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.storage-upgrade progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: var(--space-2);
|
||||
accent-color: var(--accent-solid);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { StorageUpgrade } from './StorageUpgrade'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('shows real progress and lets the user quit without deleting records', async () => {
|
||||
const act = vi.fn(async () => undefined)
|
||||
vi.stubGlobal('goodbuddy', { storageUpgrade: {
|
||||
getProgress: vi.fn(async () => ({
|
||||
stage: 'converting', processed: 32, total: 100, bytesBefore: 90_000_000_000
|
||||
})),
|
||||
act
|
||||
} } as unknown as DesktopApi)
|
||||
render(<StorageUpgrade />)
|
||||
await waitFor(() => expect(screen.getByRole('progressbar')).toHaveAttribute('value', '32'))
|
||||
expect(screen.getByText(/保留聊天、执行详情和结果/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出,稍后继续' }))
|
||||
await waitFor(() => expect(act).toHaveBeenCalledWith('quit'))
|
||||
})
|
||||
|
||||
it('keeps a failed upgrade actionable through retry', async () => {
|
||||
const act = vi.fn(async () => undefined)
|
||||
vi.stubGlobal('goodbuddy', { storageUpgrade: {
|
||||
getProgress: vi.fn(async () => ({
|
||||
stage: 'failed', processed: 32, total: 100, bytesBefore: 90_000_000_000,
|
||||
error: '磁盘空间不足'
|
||||
})),
|
||||
act
|
||||
} } as unknown as DesktopApi)
|
||||
render(<StorageUpgrade />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('磁盘空间不足')
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(act).toHaveBeenCalledWith('retry'))
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
import { PageHeader, PageShell } from './WorkspacePrimitives'
|
||||
import { useUiLocale } from './i18n/UiLocaleProvider'
|
||||
import './StorageUpgrade.css'
|
||||
|
||||
export function StorageUpgrade(): React.JSX.Element {
|
||||
const { resolvedLocale } = useUiLocale()
|
||||
const en = resolvedLocale === 'en-US'
|
||||
const [progress, setProgress] = useState<AssistantStorageProgress>()
|
||||
const [rate, setRate] = useState<number>()
|
||||
const [actionError, setActionError] = useState('')
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
let sample: { count: number; time: number } | undefined
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const next = await window.goodbuddy.storageUpgrade.getProgress()
|
||||
if (active) {
|
||||
setProgress(next)
|
||||
if (next.stage === 'converting') {
|
||||
const now = performance.now()
|
||||
if (sample && now - sample.time >= 2_000) {
|
||||
setRate(Math.max(0, (next.processed - sample.count) * 1_000 / (now - sample.time)))
|
||||
sample = { count: next.processed, time: now }
|
||||
} else if (!sample) {
|
||||
sample = { count: next.processed, time: now }
|
||||
}
|
||||
} else {
|
||||
sample = undefined
|
||||
setRate(undefined)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The normal application replaces this page when startup continues.
|
||||
}
|
||||
if (active) timer = setTimeout(() => void poll(), 500)
|
||||
}
|
||||
void poll()
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
}, [])
|
||||
const title = en ? 'Optimizing saved execution history' : '正在优化历史执行记录'
|
||||
const stage = progress?.stage
|
||||
const status = stage === 'failed'
|
||||
? progress?.error
|
||||
: stage === 'compacting'
|
||||
? en ? 'Reclaiming disk space…' : '正在回收磁盘空间…'
|
||||
: stage === 'complete'
|
||||
? en ? 'Optimization complete. Starting GoodBuddy…' : '优化完成,正在启动 GoodBuddy…'
|
||||
: progress?.stage === 'converting'
|
||||
? `${en ? 'Processed' : '已处理'} ${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`
|
||||
: en ? 'Checking saved execution history…' : '正在检查历史执行记录…'
|
||||
const act = async (action: 'retry' | 'quit'): Promise<void> => {
|
||||
try {
|
||||
setActionError('')
|
||||
await window.goodbuddy.storageUpgrade.act(action)
|
||||
} catch {
|
||||
setActionError(en ? 'The action failed. Please try again.' : '操作未完成,请重试。')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<main className="storage-upgrade" aria-labelledby="storage-upgrade-title">
|
||||
<PageShell variant="standard">
|
||||
<PageHeader
|
||||
headingId="storage-upgrade-title"
|
||||
title={title}
|
||||
description={en
|
||||
? 'This update removes repeated copies of subagent progress. Your chats, execution details and results are preserved. Time depends on the history size and disk speed.'
|
||||
: '本次更新清理子任务进度的重复副本,保留聊天、执行详情和结果。处理时间取决于历史数据量和磁盘速度。'}
|
||||
/>
|
||||
<p role={stage === 'failed' ? 'alert' : 'status'}>{status}</p>
|
||||
{progress?.stage === 'converting' && progress.total > 0 && (
|
||||
<progress aria-label={title} max={progress.total} value={progress.processed} />
|
||||
)}
|
||||
{stage === 'converting' && rate !== undefined && (
|
||||
<p>{en ? 'Processing speed' : '处理速度'}:{Math.round(rate).toLocaleString()} {en ? 'events/s' : '条/秒'}</p>
|
||||
)}
|
||||
<p>{en
|
||||
? 'You can quit and retry on the next launch. Completed batches are preserved.'
|
||||
: '可以退出并在下次启动时继续处理,已完成的批次会保留。'}</p>
|
||||
{actionError && <p role="alert">{actionError}</p>}
|
||||
{stage === 'failed' && (
|
||||
<button className="primary-button" onClick={() => void act('retry')}>
|
||||
{en ? 'Retry' : '重试'}
|
||||
</button>
|
||||
)}
|
||||
<button className="secondary-button" onClick={() => void act('quit')}>
|
||||
{en ? 'Quit and continue later' : '退出,稍后继续'}
|
||||
</button>
|
||||
</PageShell>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '@fontsource-variable/noto-sans-sc/wght.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import './markdown-math.css'
|
||||
import App from './App'
|
||||
import { StorageUpgrade } from './StorageUpgrade'
|
||||
import { installBundledUiFonts } from './fonts'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import {
|
||||
@@ -53,7 +54,9 @@ createRoot(root).render(
|
||||
<UiLocaleProvider
|
||||
initialPreference={initialUiLocalePreference}
|
||||
>
|
||||
<App />
|
||||
{new URLSearchParams(window.location.search).get('storageUpgrade') === '1'
|
||||
? <StorageUpgrade />
|
||||
: <App />}
|
||||
</UiLocaleProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const assistantStorageProgressSchema = z.object({
|
||||
stage: z.enum(['scanning', 'converting', 'compacting', 'complete', 'failed']),
|
||||
processed: z.number().int().nonnegative(),
|
||||
total: z.number().int().nonnegative(),
|
||||
bytesBefore: z.number().nonnegative(),
|
||||
bytesAfter: z.number().nonnegative().optional(),
|
||||
error: z.string().optional()
|
||||
}).strict()
|
||||
|
||||
export type AssistantStorageProgress = z.infer<
|
||||
typeof assistantStorageProgressSchema
|
||||
>
|
||||
export const assistantStorageActionSchema = z.enum(['retry', 'quit'])
|
||||
@@ -1747,6 +1747,10 @@ export type KnowledgeSearchReference = {
|
||||
}
|
||||
|
||||
export type DesktopApi = {
|
||||
storageUpgrade: {
|
||||
getProgress: () => Promise<import('./assistant-storage-contracts').AssistantStorageProgress>
|
||||
act: (action: 'retry' | 'quit') => Promise<void>
|
||||
}
|
||||
app: {
|
||||
getInfo: () => Promise<AppInfo>
|
||||
show: () => Promise<void>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const ipcChannels = {
|
||||
storageUpgradeProgress: 'app:storage-upgrade:progress',
|
||||
storageUpgradeAction: 'app:storage-upgrade:action',
|
||||
externalInstancesList: 'knowledge:external-instances:list',
|
||||
externalInstancesSave: 'knowledge:external-instances:save',
|
||||
externalInstancesTest: 'knowledge:external-instances:test',
|
||||
|
||||
Reference in New Issue
Block a user