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:
@@ -2,12 +2,17 @@ import { DatabaseSync } from 'node:sqlite'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { cpSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { extname, join, relative } from 'node:path'
|
||||
import { appendFile, open, stat } from 'node:fs/promises'
|
||||
import type { ContextAttachment } from '../shared/contracts'
|
||||
import type { ParsedDocument } from './knowledge/document-parser'
|
||||
import type { DocumentResultStorage } from './document-result-storage'
|
||||
|
||||
type AssetRow = { id: string; path: string; name: string; result_id: string | null; metadata: string }
|
||||
|
||||
// Released databases contain both Windows and POSIX relative paths.
|
||||
function portablePath(path: string): string { return path.replaceAll('\\', '/') }
|
||||
export type AttachmentReferenceKind = 'draft' | 'message' | 'queue' | 'parsing'
|
||||
|
||||
function metadata(context: ContextAttachment): string {
|
||||
const { thumbnailUrl: _thumbnail, contentUrl: _content, ...descriptor } = context
|
||||
void _thumbnail
|
||||
@@ -19,6 +24,7 @@ function originalFileName(context: Pick<ContextAttachment, 'name' | 'originalMim
|
||||
return `original${context.originalMime ? `.${context.originalMime === 'image/jpeg' ? 'jpg' : context.originalMime.slice(6)}` : extname(context.name).toLowerCase()}`
|
||||
}
|
||||
|
||||
/** Construct only in the desktop storage host. */
|
||||
export class ConversationAttachmentStorage {
|
||||
private readonly database: DatabaseSync
|
||||
constructor(private readonly root: string, private readonly results: DocumentResultStorage) {
|
||||
@@ -42,20 +48,21 @@ export class ConversationAttachmentStorage {
|
||||
PRAGMA user_version = 1; COMMIT;`)
|
||||
results.setPersistentLookup((id) => {
|
||||
const row = this.database.prepare('SELECT * FROM attachments WHERE result_id = ?').get(id) as AssetRow | undefined
|
||||
return row ? { directory: join(root, row.path), original: join(root, row.path, originalFileName({ ...JSON.parse(row.metadata), name: row.name })) } : undefined
|
||||
return row ? { directory: join(root, portablePath(row.path)), original: join(root, portablePath(row.path), originalFileName({ ...JSON.parse(row.metadata), name: row.name })) } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
private row(id: string): AssetRow {
|
||||
const row = this.database.prepare('SELECT * FROM attachments WHERE id = ?').get(id) as AssetRow | undefined
|
||||
if (!row) throw new Error('附件资源不存在,请重新添加')
|
||||
return row
|
||||
return { ...row, path: portablePath(row.path) }
|
||||
}
|
||||
|
||||
has(id: string): boolean { return Boolean(this.database.prepare('SELECT 1 FROM attachments WHERE id = ?').get(id)) }
|
||||
|
||||
save(context: ContextAttachment, serialized: string, original: Buffer): void {
|
||||
const path = join('temp', 'document-parsing', context.id)
|
||||
if (this.has(context.id)) throw new Error('Attachment already exists')
|
||||
const path = portablePath(join('temp', 'document-parsing', context.id))
|
||||
const directory = join(this.root, path)
|
||||
try {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
@@ -76,14 +83,14 @@ export class ConversationAttachmentStorage {
|
||||
discardResult(id: string): Promise<void> { return this.results.release(id) }
|
||||
|
||||
adoptDocument(context: ContextAttachment, serialized: string): void {
|
||||
const location = this.results.detach(context.resultId!)
|
||||
try {
|
||||
writeFileSync(join(location.directory, 'request.json'), serialized)
|
||||
this.database.prepare('INSERT INTO attachments VALUES (?, ?, ?, ?, ?)').run(
|
||||
context.id, relative(this.root, location.directory), context.originalName ?? context.name,
|
||||
context.resultId!, metadata(context)
|
||||
)
|
||||
} catch (error) { rmSync(location.directory, { recursive: true, force: true }); throw error }
|
||||
if (!this.results.isTemporaryResult(context.resultId!)) throw new Error('Document result is already owned')
|
||||
const location = this.results.location(context.resultId!)
|
||||
writeFileSync(join(location.directory, 'request.json'), serialized)
|
||||
this.database.prepare('INSERT INTO attachments VALUES (?, ?, ?, ?, ?)').run(
|
||||
context.id, portablePath(relative(this.root, location.directory)), context.originalName ?? context.name,
|
||||
context.resultId!, metadata(context)
|
||||
)
|
||||
this.results.detach(context.resultId!)
|
||||
}
|
||||
|
||||
get(id: string): ContextAttachment {
|
||||
@@ -103,7 +110,6 @@ export class ConversationAttachmentStorage {
|
||||
const target = this.row(targetId)
|
||||
const from = join(this.root, source.path), to = join(this.root, target.path)
|
||||
const manifest = JSON.parse(readFileSync(join(from, 'manifest.json'), 'utf8'))
|
||||
writeFileSync(join(to, 'parsed.md'), readFileSync(join(from, 'parsed.md')))
|
||||
cpSync(join(from, 'images'), join(to, 'images'), { recursive: true })
|
||||
writeFileSync(join(to, 'manifest.json'), JSON.stringify({ ...manifest, id: targetId }))
|
||||
this.database.prepare('UPDATE attachments SET result_id = ? WHERE id = ?').run(targetId, targetId)
|
||||
@@ -116,11 +122,30 @@ export class ConversationAttachmentStorage {
|
||||
return { name: row.name, data: readFileSync(path), path }
|
||||
}
|
||||
|
||||
reference(conversationId: string, kind: 'draft' | 'message' | 'queue' | 'parsing', ownerId: string, ids: string[]): void {
|
||||
reference(conversationId: string, kind: AttachmentReferenceKind, ownerId: string, ids: string[], outputHandles: string[] = []): void {
|
||||
const requiredOutputs = new Map<string, AssetRow>()
|
||||
for (const handle of new Set(outputHandles)) {
|
||||
const row = this.row(handle.slice(handle.lastIndexOf(':') + 1))
|
||||
const descriptor = JSON.parse(row.metadata)
|
||||
if (descriptor.outputHandle !== handle || !descriptor.outputFinished) throw new Error('Full output is missing or unfinished')
|
||||
requiredOutputs.set(row.id, row)
|
||||
}
|
||||
const assets: AssetRow[] = []
|
||||
for (const id of new Set(ids)) {
|
||||
const asset = this.database.prepare('SELECT * FROM attachments WHERE id = ?').get(id) as AssetRow | undefined
|
||||
if (asset) assets.push(asset)
|
||||
if (asset) assets.push({ ...asset, path: portablePath(asset.path) })
|
||||
}
|
||||
// Renderer snapshots can lag owner adoption. Only explicit history release or
|
||||
// reconciliation may remove these refs; replacing the attachment list cannot.
|
||||
const retainedOutputs = this.database.prepare(`SELECT a.* FROM attachments a
|
||||
JOIN attachment_refs r ON r.attachment_id = a.id
|
||||
WHERE r.kind = ? AND r.owner_id = ? AND json_extract(a.metadata, '$.outputHandle') IS NOT NULL
|
||||
ORDER BY r.rowid`).all(kind, ownerId) as AssetRow[]
|
||||
const included = new Set(assets.map(asset => asset.id))
|
||||
for (const asset of [...requiredOutputs.values(), ...retainedOutputs]) {
|
||||
if (included.has(asset.id)) continue
|
||||
included.add(asset.id)
|
||||
assets.push({ ...asset, path: portablePath(asset.path) })
|
||||
}
|
||||
const refs = this.database.prepare(`SELECT attachment_id, conversation_id FROM attachment_refs
|
||||
WHERE kind = ? AND owner_id = ? ORDER BY rowid`).all(kind, ownerId) as { attachment_id: string; conversation_id: string }[]
|
||||
@@ -131,7 +156,7 @@ export class ConversationAttachmentStorage {
|
||||
for (const asset of assets) {
|
||||
if (!asset.path.startsWith(`temp`)) continue
|
||||
const context = JSON.parse(asset.metadata) as ContextAttachment
|
||||
const target = join('conversation-assets', conversationId, context.kind === 'image' ? 'images' : 'documents', asset.id)
|
||||
const target = portablePath(join('conversation-assets', conversationId, context.kind === 'image' ? 'images' : 'documents', asset.id))
|
||||
mkdirSync(join(this.root, 'conversation-assets', conversationId, context.kind === 'image' ? 'images' : 'documents'), { recursive: true })
|
||||
renameSync(join(this.root, asset.path), join(this.root, target))
|
||||
try {
|
||||
@@ -157,6 +182,7 @@ export class ConversationAttachmentStorage {
|
||||
}
|
||||
|
||||
release(kind: 'draft' | 'message' | 'queue' | 'parsing', ownerId: string): void {
|
||||
// Authoritative owner deletion/cancellation, including adopted full outputs.
|
||||
this.database.prepare('DELETE FROM attachment_refs WHERE kind = ? AND owner_id = ?').run(kind, ownerId)
|
||||
}
|
||||
|
||||
@@ -165,8 +191,18 @@ export class ConversationAttachmentStorage {
|
||||
this.collect(protectedIds)
|
||||
}
|
||||
|
||||
deleteProject(conversationIds: string[], protectedIds: readonly string[] = []): void {
|
||||
this.database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const remove = this.database.prepare('DELETE FROM attachment_refs WHERE conversation_id = ?')
|
||||
for (const id of new Set(conversationIds)) remove.run(id)
|
||||
this.database.exec('COMMIT')
|
||||
} catch (error) { this.database.exec('ROLLBACK'); throw error }
|
||||
this.collect(protectedIds)
|
||||
}
|
||||
|
||||
private collectOrphanDirectories(): void {
|
||||
const owned = new Set((this.database.prepare('SELECT path FROM attachments').all() as { path: string }[]).map((row) => row.path))
|
||||
const owned = new Set((this.database.prepare('SELECT path FROM attachments').all() as { path: string }[]).map((row) => portablePath(row.path)))
|
||||
const directories = (path: string): string[] => {
|
||||
try { return readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory() && /^[0-9a-f-]{36}$/iu.test(entry.name)).map((entry) => entry.name) }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
|
||||
@@ -178,7 +214,7 @@ export class ConversationAttachmentStorage {
|
||||
}
|
||||
}
|
||||
for (const path of candidates) {
|
||||
if (owned.has(path) || this.results.isTemporaryResult(path.split(/[\\/]/u).at(-1)!)) continue
|
||||
if (owned.has(portablePath(path)) || this.results.isTemporaryResult(path.split(/[\\/]/u).at(-1)!)) continue
|
||||
rmSync(join(this.root, path), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -189,20 +225,24 @@ export class ConversationAttachmentStorage {
|
||||
(SELECT 1 FROM attachment_refs r WHERE r.attachment_id = a.id)`).all() as AssetRow[]
|
||||
for (const row of rows) {
|
||||
if (protectedSet.has(row.id)) continue
|
||||
try {
|
||||
rmSync(join(this.root, row.path), { recursive: true, force: true })
|
||||
this.database.prepare('DELETE FROM attachments WHERE id = ?').run(row.id)
|
||||
} catch {
|
||||
// The unreferenced row remains authoritative for the next cleanup attempt.
|
||||
console.warn('Attachment cleanup deferred; unreferenced files will be retried on the next cleanup')
|
||||
}
|
||||
this.collectRow(row)
|
||||
}
|
||||
}
|
||||
|
||||
private collectRow(row: AssetRow): void {
|
||||
try {
|
||||
rmSync(join(this.root, portablePath(row.path)), { recursive: true, force: true })
|
||||
this.database.prepare('DELETE FROM attachments WHERE id = ?').run(row.id)
|
||||
} catch {
|
||||
// The unreferenced row remains authoritative for the next cleanup attempt.
|
||||
console.warn('Attachment cleanup deferred; unreferenced files will be retried on the next cleanup')
|
||||
}
|
||||
}
|
||||
|
||||
reconcile(hasOwner: (conversationId: string, kind: string, ownerId: string) => boolean, startup = false, protectedIds: readonly string[] = []): void {
|
||||
const owners = this.database.prepare('SELECT DISTINCT conversation_id, kind, owner_id FROM attachment_refs').all() as { conversation_id: string; kind: string; owner_id: string }[]
|
||||
for (const owner of owners) {
|
||||
if (!hasOwner(owner.conversation_id, owner.kind, owner.owner_id)) {
|
||||
if (owner.kind === 'output-call' ? startup : !hasOwner(owner.conversation_id, owner.kind, owner.owner_id)) {
|
||||
this.database.prepare('DELETE FROM attachment_refs WHERE conversation_id = ? AND kind = ? AND owner_id = ?').run(owner.conversation_id, owner.kind, owner.owner_id)
|
||||
}
|
||||
}
|
||||
@@ -229,5 +269,76 @@ export class ConversationAttachmentStorage {
|
||||
WHERE r.kind = 'parsing' AND r.conversation_id = ? AND json_extract(a.metadata, '$.parsingState') IN ('failed', 'interrupted')`).all(conversationId) as { id: string }[]).map((row) => this.get(row.id))
|
||||
}
|
||||
|
||||
private output(ownerId: string, handle: string, requireCall = true): AssetRow {
|
||||
const row = this.row(handle.slice(handle.lastIndexOf(':') + 1))
|
||||
const descriptor = JSON.parse(row.metadata)
|
||||
const call = this.database.prepare("SELECT 1 FROM attachment_refs WHERE attachment_id = ? AND kind = 'output-call' AND owner_id = ?").get(row.id, ownerId)
|
||||
const history = !requireCall && this.database.prepare('SELECT 1 FROM attachment_refs WHERE attachment_id = ? AND conversation_id = ?').get(row.id, ownerId)
|
||||
if (descriptor.outputHandle !== handle || (!call && !history)) throw new Error('Output does not belong to this owner')
|
||||
return row
|
||||
}
|
||||
|
||||
outputCreate(ownerId: string, handle: string): void {
|
||||
const id = handle.slice(handle.lastIndexOf(':') + 1)
|
||||
if (!/^[0-9a-f-]{36}$/iu.test(id)) throw new Error('Invalid output handle')
|
||||
const context = { id, name: 'output.txt', kind: 'text' as const, size: 0, preview: '', outputHandle: handle, outputFinished: false }
|
||||
this.save(context, JSON.stringify(context), Buffer.alloc(0))
|
||||
try {
|
||||
this.database.prepare('INSERT INTO attachment_refs VALUES (?, ?, ?, ?)').run(id, '', 'output-call', ownerId)
|
||||
} catch (error) { this.collectRow(this.row(id)); throw error }
|
||||
}
|
||||
|
||||
async outputAppend(ownerId: string, handle: string, bytes: Uint8Array): Promise<void> {
|
||||
if (bytes.byteLength > 64 * 1024) throw new Error('Output append exceeds 64 KiB')
|
||||
const row = this.output(ownerId, handle)
|
||||
if (JSON.parse(row.metadata).outputFinished) throw new Error('Output is already finished')
|
||||
await appendFile(join(this.root, row.path, 'original.txt'), bytes)
|
||||
}
|
||||
|
||||
async outputFinish(ownerId: string, handle: string, totalBytes: number): Promise<void> {
|
||||
const row = this.output(ownerId, handle)
|
||||
const file = await stat(join(this.root, row.path, 'original.txt'))
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes < 0 || file.size !== totalBytes) throw new Error('Incomplete stored output')
|
||||
this.database.prepare('UPDATE attachments SET metadata = ? WHERE id = ?').run(
|
||||
JSON.stringify({ ...JSON.parse(row.metadata), size: totalBytes, outputFinished: true }), row.id)
|
||||
}
|
||||
|
||||
async outputRead(ownerId: string, handle: string, cursor: number, length: number): Promise<Uint8Array> {
|
||||
if (!Number.isSafeInteger(cursor) || cursor < 0 || !Number.isSafeInteger(length) || length < 0 || length > 32 * 1024 + 3) throw new Error('Invalid output page')
|
||||
const row = this.output(ownerId, handle, false)
|
||||
const file = await open(join(this.root, row.path, 'original.txt'), 'r')
|
||||
try {
|
||||
const bytes = Buffer.alloc(length)
|
||||
const { bytesRead } = await file.read(bytes, 0, length, cursor)
|
||||
return bytes.subarray(0, bytesRead)
|
||||
} finally { await file.close() }
|
||||
}
|
||||
|
||||
outputAdopt(ownerId: string, handle: string, conversationId: string, kind: AttachmentReferenceKind, referenceId: string): string {
|
||||
const row = this.output(ownerId, handle)
|
||||
if (!JSON.parse(row.metadata).outputFinished) throw new Error('Output capture has not finished')
|
||||
const existing = this.database.prepare('SELECT attachment_id FROM attachment_refs WHERE kind = ? AND owner_id = ? ORDER BY rowid').all(kind, referenceId) as { attachment_id: string }[]
|
||||
this.reference(conversationId, kind, referenceId, [...existing.map(item => item.attachment_id), row.id])
|
||||
return row.id
|
||||
}
|
||||
|
||||
outputRelease(ownerId: string, handle: string): void {
|
||||
const id = handle.slice(handle.lastIndexOf(':') + 1)
|
||||
if (!this.has(id)) return
|
||||
const row = this.row(id)
|
||||
if (JSON.parse(row.metadata).outputHandle !== handle) throw new Error('Invalid output handle')
|
||||
this.database.prepare("DELETE FROM attachment_refs WHERE attachment_id = ? AND kind = 'output-call' AND owner_id = ?").run(id, ownerId)
|
||||
// Releasing one call must not collect Main's not-yet-referenced attachments.
|
||||
if (!this.database.prepare('SELECT 1 FROM attachment_refs WHERE attachment_id = ?').get(id)) this.collectRow(row)
|
||||
}
|
||||
|
||||
releaseOutputs(): void {
|
||||
const calls = this.database.prepare("SELECT attachment_id, owner_id FROM attachment_refs WHERE kind = 'output-call'").all() as { attachment_id: string; owner_id: string }[]
|
||||
for (const call of calls) {
|
||||
const descriptor = JSON.parse(this.row(call.attachment_id).metadata)
|
||||
this.outputRelease(call.owner_id, descriptor.outputHandle)
|
||||
}
|
||||
}
|
||||
|
||||
close(): void { this.database.close() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user