feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.
- Remove the Main readWithFallback path and the supervision worker's
separate business write connection; manifests are scanned read-only and
committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
directories and reclaim confirmed stale ones; stop writing duplicate
parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
acceptance fixtures, and maintenance/architecture documentation.
Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { mkdir, mkdtemp, open, rm, stat, type FileHandle } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import type { PagedOutputBackingStore } from '../../src/main/agent/paged-output-store'
|
||||
|
||||
export const outputFixtureRoot = resolve('temp/goodbuddy-output-upgrade')
|
||||
|
||||
// Real asynchronous file IO for contract tests, not a production storage owner.
|
||||
export class FileOutputBacking implements PagedOutputBackingStore {
|
||||
private directory?: Promise<string>
|
||||
private readonly entries = new Map<string, { owner: string; path: string; file?: FileHandle }>()
|
||||
writes = 0
|
||||
maximumWriteBytes = 0
|
||||
|
||||
async create(owner: string, handle: string): Promise<void> {
|
||||
this.directory ??= mkdir(outputFixtureRoot, { recursive: true }).then(() =>
|
||||
mkdtemp(join(outputFixtureRoot, 'backing-')))
|
||||
const path = join(await this.directory, handle.replace(':', '-'))
|
||||
const file = await open(path, 'wx+')
|
||||
this.entries.set(handle, { owner, path, file })
|
||||
}
|
||||
|
||||
private entry(owner: string, handle: string) {
|
||||
const entry = this.entries.get(handle)
|
||||
if (!entry || entry.owner !== owner) throw new Error('Unknown output owner')
|
||||
return entry
|
||||
}
|
||||
|
||||
async append(owner: string, handle: string, bytes: Uint8Array): Promise<void> {
|
||||
const { file } = this.entry(owner, handle)
|
||||
if (!file) throw new Error('Output already finished')
|
||||
this.writes += 1
|
||||
this.maximumWriteBytes = Math.max(this.maximumWriteBytes, bytes.byteLength)
|
||||
for (let offset = 0; offset < bytes.byteLength;) {
|
||||
const { bytesWritten } = await file.write(bytes, offset, bytes.byteLength - offset)
|
||||
if (!bytesWritten) throw new Error('Output write made no progress')
|
||||
offset += bytesWritten
|
||||
}
|
||||
}
|
||||
|
||||
async finish(owner: string, handle: string, totalBytes: number): Promise<void> {
|
||||
const entry = this.entry(owner, handle)
|
||||
await entry.file?.close()
|
||||
entry.file = undefined
|
||||
if ((await stat(entry.path)).size !== totalBytes) throw new Error('Output size mismatch')
|
||||
}
|
||||
|
||||
async read(owner: string, handle: string, cursor: number, length: number): Promise<Uint8Array> {
|
||||
const file = await open(this.entry(owner, handle).path, 'r')
|
||||
try {
|
||||
const bytes = Buffer.alloc(length)
|
||||
let offset = 0
|
||||
while (offset < length) {
|
||||
const { bytesRead } = await file.read(bytes, offset, length - offset, cursor + offset)
|
||||
if (!bytesRead) break
|
||||
offset += bytesRead
|
||||
}
|
||||
return bytes.subarray(0, offset)
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
||||
async release(owner: string, handle: string): Promise<void> {
|
||||
if (!this.entries.has(handle)) return
|
||||
const entry = this.entry(owner, handle)
|
||||
await entry.file?.close()
|
||||
entry.file = undefined
|
||||
await rm(entry.path, { force: true })
|
||||
this.entries.delete(handle)
|
||||
}
|
||||
|
||||
get count(): number { return this.entries.size }
|
||||
get createdDirectory(): boolean { return this.directory !== undefined }
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
for (const [handle, entry] of this.entries) await this.release(entry.owner, handle)
|
||||
if (this.directory) await rm(await this.directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user