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

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

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

88 lines
3.8 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { utilityProcess, type UtilityProcess } from 'electron'
import type { ReadonlyQueryRequest, QueryTransport } from './readonly-query-reader'
import type { StorageRequest, StorageResponse } from './desktop-storage-contracts'
const EXIT_TIMEOUT_MS = 10_000
/** Adapts Electron messages to the existing query reader, without another request queue. */
export class DesktopStorageTransport extends EventEmitter implements QueryTransport {
private readonly child: UtilityProcess
private readonly exit: Promise<number>
private exitCode?: number
private closeAcknowledged = false
private closing?: Promise<number>
private failed = false
constructor(modulePath: string) {
super()
this.child = utilityProcess.fork(modulePath, [], {
serviceName: 'GoodBuddy Desktop Storage', stdio: 'ignore', allowLoadingUnsignedLibraries: false
})
this.exit = new Promise(resolve => this.child.once('exit', code => {
this.exitCode = code
resolve(code)
this.emit('exit', code)
}))
this.child.on('error', () => this.fail(new Error('Desktop storage process failed')))
this.child.on('spawn', () => this.emit('spawn'))
this.child.on('message', (message: StorageResponse) => {
if (message.type === 'closed') this.closeAcknowledged = true
if (message.type === 'failed' && message.error.code === 'STORAGE_CLOSE_FAILED') this.failed = true
if (message.type === 'result') this.emit('message', { id: message.id, result: message.result })
else if (message.type === 'error') this.emit('message', { id: message.id, error: message.error })
else this.emit('storageMessage', message)
})
}
private fail(error: unknown): void {
if (this.failed || this.exitCode !== undefined) return
this.failed = true
try { this.emit('storageFailure', error) } finally { this.child.kill() }
}
send(message: StorageRequest): void {
if (this.exitCode !== undefined) throw new Error('Desktop storage process has exited; unconfirmed writes must not be replayed automatically')
this.child.postMessage(message)
}
postMessage(message: ReadonlyQueryRequest | { type: 'cancel'; id: number }): void {
if (message.type === 'cancel') {
if (this.exitCode === undefined) this.child.postMessage(message)
return
}
const separator = message.op.indexOf('.')
// Throws synchronously (for example DataCloneError); the reader rejects that call.
this.send({ type: 'call', id: message.id,
domain: message.op.slice(0, separator) as Extract<StorageRequest, { type: 'call' }>['domain'],
method: message.op.slice(separator + 1), args: message.args })
}
get needsReplacement(): boolean { return this.failed || this.exitCode !== undefined }
waitForExit(): Promise<number> {
return withTimeout(this.exit, 'Desktop storage exit was not confirmed; replacement was not started')
}
ref(): void { /* Utility process lifetime is owned by application shutdown. */ }
unref(): void { /* No worker-thread ref counting is needed for utilityProcess. */ }
terminate(): Promise<number> {
this.closing ??= withTimeout((async () => {
if (this.exitCode === undefined) this.send({ type: 'close' })
return this.exit
})(), 'Desktop storage exit was not confirmed').then(code => {
if (!this.closeAcknowledged || code !== 0) throw new Error(`Desktop storage exited without a successful drain (${code}); unconfirmed writes require domain reconciliation`)
return code
})
return this.closing
}
}
async function withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([promise, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(message)), EXIT_TIMEOUT_MS) })])
} finally { clearTimeout(timer) }
}