Files
goodbuddy/src/main/conversation-attachment-storage.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

345 lines
20 KiB
TypeScript

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
void _content
return JSON.stringify(descriptor)
}
function originalFileName(context: Pick<ContextAttachment, 'name' | 'originalMime'>): string {
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) {
mkdirSync(root, { recursive: true })
this.database = new DatabaseSync(join(root, 'conversation-attachments.sqlite'))
const version = this.database.prepare('PRAGMA user_version').get() as { user_version: number }
if (version.user_version > 1) { this.database.close(); throw new Error('附件数据库版本较新,请升级应用') }
this.database.exec(`PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 5000;`)
if (version.user_version === 0) this.database.exec(`BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY, path TEXT NOT NULL, name TEXT NOT NULL,
result_id TEXT UNIQUE, metadata TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS attachment_refs (
attachment_id TEXT NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
conversation_id TEXT NOT NULL, kind TEXT NOT NULL, owner_id TEXT NOT NULL,
PRIMARY KEY (attachment_id, kind, owner_id)
);
CREATE INDEX IF NOT EXISTS attachment_refs_owner ON attachment_refs(kind, owner_id);
CREATE INDEX IF NOT EXISTS attachment_refs_conversation ON attachment_refs(conversation_id);
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, 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, 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 {
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 })
writeFileSync(join(directory, originalFileName({ ...context, name: context.originalName ?? context.name })), original)
writeFileSync(join(directory, 'request.json'), serialized)
this.database.prepare('INSERT INTO attachments VALUES (?, ?, ?, ?, ?)').run(
context.id, path, context.originalName ?? context.name, context.resultId ?? null, metadata(context)
)
} catch (error) { rmSync(directory, { recursive: true, force: true }); throw error }
}
async saveDocument(name: string, original: Buffer, parsed: ParsedDocument): Promise<string> {
if (!parsed.parsingSettings) throw new Error('解析结果缺少生效配置')
const result = await this.results.save(name, original, parsed, parsed.parsingSettings, parsed.parsingDurationMs ?? 0)
return result.id
}
discardResult(id: string): Promise<void> { return this.results.release(id) }
adoptDocument(context: ContextAttachment, serialized: string): void {
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 {
const descriptor = JSON.parse(this.row(id).metadata) as ContextAttachment
const request = JSON.parse(this.request(id)) as ContextAttachment
return { ...descriptor, ...(request.thumbnailUrl ? { thumbnailUrl: request.thumbnailUrl } : {}), ...(request.kind === 'image' && 'data' in request && typeof request.data === 'string' && request.data.length <= 399_970 ? { contentUrl: `data:image/jpeg;base64,${request.data}` } : {}) }
}
update(context: ContextAttachment, serialized: string): void {
const row = this.row(context.id)
writeFileSync(join(this.root, row.path, 'request.json'), serialized)
this.database.prepare('UPDATE attachments SET metadata = ? WHERE id = ?').run(metadata(context), context.id)
}
copyResult(sourceId: string, targetId: string): boolean {
const source = this.row(sourceId)
if (!source.result_id) return false
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'))
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)
return true
}
request(id: string): string { return readFileSync(join(this.root, this.row(id).path, 'request.json'), 'utf8') }
original(id: string): { name: string; data: Buffer; path: string } {
const row = this.row(id)
const path = join(this.root, row.path, originalFileName({ ...JSON.parse(row.metadata), name: row.name }))
return { name: row.name, data: readFileSync(path), path }
}
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, 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 }[]
// Draft order is visible, and matching references can still point to temporary files.
if (refs.length === assets.length && assets.every((asset, index) =>
refs[index]?.attachment_id === asset.id && refs[index]?.conversation_id === conversationId && !asset.path.startsWith('temp')
)) return
for (const asset of assets) {
if (!asset.path.startsWith(`temp`)) continue
const context = JSON.parse(asset.metadata) as ContextAttachment
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 {
this.database.prepare('UPDATE attachments SET path = ? WHERE id = ?').run(target, asset.id)
} catch (error) {
renameSync(join(this.root, target), join(this.root, asset.path))
throw error
}
}
this.database.exec('BEGIN IMMEDIATE')
try {
this.database.prepare('DELETE FROM attachment_refs WHERE kind = ? AND owner_id = ?').run(kind, ownerId)
const insert = this.database.prepare('INSERT INTO attachment_refs VALUES (?, ?, ?, ?)')
for (const asset of assets) insert.run(asset.id, conversationId, kind, ownerId)
this.database.exec('COMMIT')
} catch (error) { this.database.exec('ROLLBACK'); throw error }
}
draft(conversationId: string): ContextAttachment[] {
return (this.database.prepare(`SELECT a.id FROM attachments a JOIN attachment_refs r ON r.attachment_id = a.id
WHERE r.kind = 'draft' AND r.owner_id = ? ORDER BY r.rowid`).all(conversationId) as { id: string }[])
.map((row) => this.get(row.id))
}
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)
}
deleteConversation(conversationId: string, protectedIds: readonly string[] = []): void {
this.database.prepare('DELETE FROM attachment_refs WHERE conversation_id = ?').run(conversationId)
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) => 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 }
}
const candidates = directories(join(this.root, 'temp', 'document-parsing')).map((id) => join('temp', 'document-parsing', id))
for (const conversationId of directories(join(this.root, 'conversation-assets'))) {
for (const kind of ['images', 'documents']) {
for (const id of directories(join(this.root, 'conversation-assets', conversationId, kind))) candidates.push(join('conversation-assets', conversationId, kind, id))
}
}
for (const path of candidates) {
if (owned.has(portablePath(path)) || this.results.isTemporaryResult(path.split(/[\\/]/u).at(-1)!)) continue
rmSync(join(this.root, path), { recursive: true, force: true })
}
}
collect(protectedIds: readonly string[] = []): void {
const protectedSet = new Set(protectedIds)
const rows = this.database.prepare(`SELECT a.* FROM attachments a WHERE NOT EXISTS
(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
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 (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)
}
}
if (startup) this.database.exec("UPDATE attachments SET metadata = json_set(metadata, '$.parsingState', 'interrupted') WHERE json_extract(metadata, '$.parsingState') = 'parsing'")
this.collect(protectedIds)
if (startup) this.collectOrphanDirectories()
}
beginParsing(conversationId: string, name: string, original: Buffer): string {
const id = randomUUID()
const context: ContextAttachment = { id, resourceId: id, name, originalName: name, originalSize: original.length, kind: 'text', size: 0, preview: '', parsingState: 'parsing' }
this.save(context, JSON.stringify(context), original)
this.reference(conversationId, 'parsing', id, [id])
return id
}
parsingFailed(id: string, interrupted: boolean, error: string): void {
const context = this.get(id)
this.update({ ...context, parsingState: interrupted ? 'interrupted' : 'failed', parsingError: error.slice(0, 1000) }, this.request(id))
}
pendingParsing(conversationId: string): ContextAttachment[] {
return (this.database.prepare(`SELECT a.id FROM attachments a JOIN attachment_refs r ON r.attachment_id = a.id
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() }
}