diff --git a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts index e4e0fd31ca..44657f92e6 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts @@ -1704,7 +1704,7 @@ describe('FORGE_GUIDE', () => { it('documents library capabilities as a sessionless support list with stable failure reasons', () => { expect(FORGE_GUIDE).toContain("op: 'capabilities'"); - expect(FORGE_GUIDE).toContain("operations:['clipboardWrite','saveAs']"); + expect(FORGE_GUIDE).toContain("operations:['clipboardWrite','saveAs','staging.begin'"); expect(FORGE_GUIDE).toContain('不等于此刻有窗口 / 已授权 / 库可用'); expect(FORGE_GUIDE).toContain('全部字符串'); expect(FORGE_GUIDE).toContain('数组内混入'); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts index 396ed5aebd..9fadc3e5c9 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts @@ -49,6 +49,10 @@ describe('LibraryBindingStore', () => { expect(set.ok).toBe(true); if (!set.ok) return; expect(set.record.generation).toBe(1); + expect(set.record.libraryReady).toBe(false); + await store.markLibraryReady(GHOST_ID); + expect((await store.getBinding(GHOST_ID))?.libraryReady).toBe(true); + expect((await store.getBinding(GHOST_ID))?.generation).toBe(1); const after = await store.resolveLibraryRoot(GHOST_ID); expect(after.kind).toBe('custom'); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index fd4bffff58..e250906cd2 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -26,6 +26,7 @@ import { createLibraryDbCore, type SqliteDatabaseConstructor } from '../libraryD import { LibrarySqlService } from '../librarySqlService.js'; import { classifyGhostLibraryOperationSupport, + GHOST_LIBRARY_CAPABILITIES_V1, type InstalledGhost, } from '../../../shared/ghost.js'; @@ -111,6 +112,7 @@ describe('GhostLibrarySlot', () => { showSaveDialog: (...args: unknown[]) => showSaveDialog(...args), writeClipboardPng: (...args: unknown[]) => writeClipboardPng(...args), syncAgentReadonlyExtraDir: (...args: unknown[]) => syncAgentReadonlyExtraDir(...args), + getStagingRoot: (id) => path.join(tmp, 'library-staging', id), now: () => clock, }; showItemInFolder = vi.fn(); @@ -165,7 +167,11 @@ describe('GhostLibrarySlot', () => { expect(r).toEqual({ ok: true, op: 'capabilities', - capabilities: { version: 1, operations: ['clipboardWrite', 'saveAs'] }, + capabilities: { + version: 1, + operations: [...GHOST_LIBRARY_CAPABILITIES_V1.operations], + staging: { ...GHOST_LIBRARY_CAPABILITIES_V1.staging }, + }, }); expect(classifyGhostLibraryOperationSupport(r, 'clipboardWrite')).toBe('supported'); expect(classifyGhostLibraryOperationSupport(r, 'saveAs')).toBe('supported'); @@ -359,7 +365,11 @@ describe('GhostLibrarySlot', () => { expect(capsWhileUnavailable).toEqual({ ok: true, op: 'capabilities', - capabilities: { version: 1, operations: ['clipboardWrite', 'saveAs'] }, + capabilities: { + version: 1, + operations: [...GHOST_LIBRARY_CAPABILITIES_V1.operations], + staging: { ...GHOST_LIBRARY_CAPABILITIES_V1.staging }, + }, }); // 绝不落默认根冒充。 expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'a.txt'))).toBe(false); @@ -432,6 +442,27 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(candidate, GHOST_ID, 'keep.txt'))).toBe(false); }); + it('custom 已 ready 后只丢 ghost 子目录: dispose 后再 open 不得重建空库', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + expect((await bindingStore.getBinding(GHOST_ID))?.libraryReady).toBe(true); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const customRoot = path.join(candidate, GHOST_ID); + await fs.promises.rename(customRoot, `${customRoot}.parked`); + await slot.disposeAll(); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('disk-missing'); + expect(fs.existsSync(path.join(customRoot, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(`${customRoot}.parked`, 'keep.txt'))).toBe(true); + const blocked = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'empty.txt', content: 'nope' }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + }); it('delayed resolveLibraryRoot: stale custom after parent rename is disk-missing without recreating empty library', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); @@ -1582,4 +1613,449 @@ describe('GhostLibrarySlot', () => { expect(resolveLibraryAssetPath(rootA, path.join(rootA, rel))).toBeNull(); expect(resolveLibraryAssetPath('relative-root', ref)).toBeNull(); }); + + it('staging 在 Library 根不可用时仍可 commit;release 只认当前 Library ACK', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const recovery = { sceneId: 's1', nodeId: 'n1' }; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', + taskId: 'task-1', + sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), + sha256: sha, + mime: 'image/png', + recovery, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + expect(resolveLibraryRoot).not.toHaveBeenCalled(); + const chunk = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + expect(chunk).toMatchObject({ ok: true, op: 'staging.chunk', accepted: Buffer.byteLength(body) }); + const commit = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + expect(commit).toMatchObject({ + ok: true, op: 'staging.commit', stagingId: begin.stagingId, taskId: 'task-1', + sourceRevision: 'rev-1', sha256: sha, bytes: Buffer.byteLength(body), mime: 'image/png', durable: true, + }); + const listed = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!listed.ok || listed.op !== 'staging.list') throw new Error(JSON.stringify(listed)); + expect(listed.items).toHaveLength(1); + expect(listed.items[0]?.recovery).toEqual(recovery); + + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + await fs.promises.rename(candidate, `${candidate}.parked`); + const missingRelease = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: '0'.repeat(64), + libraryGeneration: 1, + }); + expect(missingRelease).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + await fs.promises.rename(`${candidate}.parked`, candidate); + + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toEqual({ ok: true, op: 'staging.release', stagingId: begin.stagingId, released: true }); + const listedAfter = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!listedAfter.ok || listedAfter.op !== 'staging.list') throw new Error(JSON.stringify(listedAfter)); + expect(listedAfter.items).toEqual([]); + expect(fs.existsSync(path.join(tmp, 'library-staging', GHOST_ID, 'tasks', begin.stagingId, 'blob.bin'))).toBe(false); + }); + + it('staging.release 在 hash 期间 relocate/dispose 后不得删原件', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-relocate', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + const orig = LibraryVault.prototype.hashFile; + const spy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(async function (this: LibraryVault, relPath: string) { + slot.setRelocating(GHOST_ID, true); + return orig.call(this, relPath); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + } finally { + spy.mockRestore(); + slot.setRelocating(GHOST_ID, false); + } + const still = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!still.ok || still.op !== 'staging.list') throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(begin.stagingId); + }); + + it('staging.release 路径形状对但 hash 前缀不符则 ACK_MISMATCH', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const wrong = `assets/aa/${'a'.repeat(64)}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-path', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: wrong, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: wrong, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: (open as { libraryIdentity?: string }).libraryIdentity, + libraryGeneration: (open as { libraryGeneration?: number }).libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + }); + + async function commitAndArchive(taskId: string, payload = 'pixel-bytes') { + const sha = createHash('sha256').update(payload).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId, sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(payload), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + return { stagingId: begin.stagingId, sha, rel, archived, payload }; + } + + it('staging.release ACK 后 tombstone 写入前切根则保留原件', async () => { + const { stagingId, sha, rel, archived } = await commitAndArchive('task-ack-then-root'); + const origWrite = LibraryVault.prototype.write; + const spy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('tombstone.json')) { + const sessions = (slot as unknown as { sessions: Map }).sessions; + const live = sessions.get(GHOST_ID); + if (live) { + live.identity = 'f'.repeat(64); + live.generation = live.generation + 1; + } + } + return origWrite.call(this, req); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + } finally { + spy.mockRestore(); + } + const still = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!still.ok || still.op !== 'staging.list') throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(stagingId); + expect(fs.existsSync(path.join(tmp, 'library-staging', GHOST_ID, 'tasks', stagingId, 'blob.bin'))).toBe(true); + }); + + it('owner lease 覆盖 ACK await,切账号后不得删原件', async () => { + let leaseHeld = false; + let leaseReleased = false; + await slot.disposeAll(); + slot = new GhostLibrarySlot({ + getGhost: (id) => ghosts.get(id) ?? null, + bindingStore, + getDefaultRoot: (id) => path.join(defaultRootBase, id), + captureOwnerScope: () => captureOwnerScope(), + createVault: (d) => createVault(d), + createSqlService: (d) => createSqlService(d), + getDiskFreeBytes: async () => 1024 ** 4, + workerScriptPath: () => path.join(tmp, 'unused-worker.js'), + betterSqliteModulePath: () => 'better-sqlite3', + showItemInFolder: (...args: unknown[]) => showItemInFolder(...args), + showSaveDialog: (...args: unknown[]) => showSaveDialog(...args), + writeClipboardPng: (...args: unknown[]) => writeClipboardPng(...args), + syncAgentReadonlyExtraDir: (...args: unknown[]) => syncAgentReadonlyExtraDir(...args), + getStagingRoot: (id) => path.join(tmp, 'library-staging', id), + now: () => clock, + captureMutationOwner: () => ({ mode: 'local', dataOwnerId: 'a', generation: 1 }), + beginMutation: () => { + leaseHeld = true; + return () => { leaseReleased = true; leaseHeld = false; }; + }, + }); + const { stagingId, sha, rel, archived } = await commitAndArchive('task-lease-switch'); + expect(leaseHeld).toBe(false); + const orig = LibraryVault.prototype.hashFile; + const spy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(async function (this: LibraryVault, relPath: string) { + expect(leaseHeld).toBe(true); + if (relPath.startsWith('assets/')) scopeKey = 'local:owner-b:1'; + return orig.call(this, relPath); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'OWNER_CHANGED' }); + expect(leaseReleased).toBe(true); + } finally { + spy.mockRestore(); + scopeKey = 'local:owner-a:1'; + } + const still = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!still.ok || still.op !== 'staging.list') throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(stagingId); + }); + + it('disposeGhost 等待在途 release 完成后再切会话', async () => { + const { stagingId, sha, rel, archived } = await commitAndArchive('task-drain'); + let disposeDone = false; + let disposeDuringWrite = false; + let pendingDispose: Promise | undefined; + const origWrite = LibraryVault.prototype.write; + const spy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('tombstone.json')) { + pendingDispose = slot.disposeGhost(GHOST_ID).then(() => { disposeDone = true; }); + await new Promise((resolve) => setTimeout(resolve, 20)); + disposeDuringWrite = disposeDone; + } + return origWrite.call(this, req); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toEqual({ ok: true, op: 'staging.release', stagingId, released: true }); + expect(disposeDuringWrite).toBe(false); + await pendingDispose; + expect(disposeDone).toBe(true); + } finally { + spy.mockRestore(); + } + }); + + it('disposeAll 在 tombstone 窗口先置 relocating 并排空,新 release 不得进入', async () => { + const { stagingId, sha, rel, archived } = await commitAndArchive('task-dispose-all'); + let disposeAllDuringWrite = false; + let pendingDispose: Promise | undefined; + const origWrite = LibraryVault.prototype.write; + const spy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('tombstone.json')) { + pendingDispose = slot.disposeAll(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const racing = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + disposeAllDuringWrite = racing.ok === false && (racing as { errorCode?: string }).errorCode === 'ACK_MISMATCH'; + } + return origWrite.call(this, req); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toEqual({ ok: true, op: 'staging.release', stagingId, released: true }); + expect(disposeAllDuringWrite).toBe(true); + await pendingDispose; + } finally { + spy.mockRestore(); + } + }); + + it('staging.release 首次 hash 后正本被删: ACK_MISMATCH 且保留 staging 原件', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-hash-delete', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + const orig = LibraryVault.prototype.hashFile; + let seen = 0; + const spy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(async function (this: LibraryVault, relPath: string) { + const hashed = await orig.call(this, relPath); + seen += 1; + if (seen === 1 && relPath === rel) { + await fs.promises.rm(path.join(defaultRootBase, GHOST_ID, rel), { force: true }); + } + return hashed; + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + } finally { + spy.mockRestore(); + } + const still = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!still.ok || still.op !== 'staging.list') throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(begin.stagingId); + expect(fs.existsSync(path.join(tmp, 'library-staging', GHOST_ID, 'tasks', begin.stagingId, 'blob.bin'))).toBe(true); + }); + + it('staging.release 与并发 delete 互斥:核验结束前正本仍在,release 成功后再删', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-release-mutex', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + const canonical = path.join(defaultRootBase, GHOST_ID, rel); + let resume!: () => void; + const held = new Promise((resolve) => { resume = resolve; }); + let entered!: () => void; + const started = new Promise((resolve) => { entered = resolve; }); + const orig = LibraryVault.prototype.hashFile; + let heldOnce = false; + const spy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(async function (this: LibraryVault, relPath: string) { + if (relPath === rel && !heldOnce) { + heldOnce = true; + entered(); + await held; + } + return orig.call(this, relPath); + }); + try { + const releaseP = slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + await started; + const deleteP = slot.handleLibraryRequest(GHOST_ID, { op: 'delete', path: rel }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fs.existsSync(canonical)).toBe(true); + resume(); + const released = await releaseP; + expect(released).toEqual({ + ok: true, op: 'staging.release', stagingId: begin.stagingId, released: true, + }); + const deleted = await deleteP; + expect(deleted).toMatchObject({ ok: true, op: 'delete' }); + expect(fs.existsSync(canonical)).toBe(false); + expect(fs.existsSync(path.join(tmp, 'library-staging', GHOST_ID, 'tasks', begin.stagingId, 'blob.bin'))).toBe(false); + } finally { + spy.mockRestore(); + } + }); + + it('disposeAll 释放 stagingStores', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-dispose-stores', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + const stores = (slot as unknown as { stagingStores: Map }).stagingStores; + expect(stores.size).toBeGreaterThan(0); + await slot.disposeAll(); + expect(stores.size).toBe(0); + }); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts new file mode 100644 index 0000000000..9c045c8b9f --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts @@ -0,0 +1,1149 @@ +/** + * Host H1 LibraryStagingStore 故障恢复单测。 + * 每条用例执行标题声称的完整序列(失败 → 保留原件 → 去掉故障 → 同实例/新实例恢复), + * 禁止只断言初次失败。tmpdir 合成数据,零 Electron,不读真实 profile/Library。 + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; + +import { LibraryStagingStore, DEFAULT_LIBRARY_STAGING_LIMITS } from '../libraryStaging.js'; +import { LibraryVault, DEFAULT_LIBRARY_LIMITS } from '../libraryVault.js'; + +const sha256Of = (s: string | Buffer): string => createHash('sha256').update(s).digest('hex'); +const HEX_A = 'a'.repeat(64); +const DEFAULT_READ_CHUNK = DEFAULT_LIBRARY_STAGING_LIMITS.maxChunkBytes; + +describe('LibraryStagingStore 故障恢复', () => { + let tmp: string; + let scope: string | null = 'local:owner-a:1'; + const ghostId = 'mivo-canvas'; + const body = 'pixel-bytes'; + const sha = sha256Of(body); + const recovery = { sceneId: 's1', nodeId: 'n1' }; + + const makeStore = ( + root = path.join(tmp, 'library-staging', ghostId), + extra: { + maxTotalBytes?: number; + maxConcurrentWrites?: number; + maxChunkBytes?: number; + maxTaskBytes?: number; + reserveBytes?: number; + streamIdleTimeoutMs?: number; + listPageSize?: number; + now?: () => number; + getDiskFreeBytes?: () => Promise; + ownerScopeKey?: string; + } = {}, + ): LibraryStagingStore => + new LibraryStagingStore({ + rootDir: root, + ownerScopeKey: extra.ownerScopeKey ?? 'local:owner-a:1', + ghostId, + captureOwnerScope: () => extra.ownerScopeKey ?? scope, + createVault: (deps) => new LibraryVault({ + ...deps, + limits: { + ...deps.limits, + ...(extra.listPageSize !== undefined ? { listPageSize: extra.listPageSize } : {}), + }, + }), + getDiskFreeBytes: extra.getDiskFreeBytes ?? (async () => 1024 ** 4), + now: extra.now, + limits: { + maxTotalBytes: extra.maxTotalBytes ?? 64, + maxConcurrentWrites: extra.maxConcurrentWrites ?? 2, + reserveBytes: extra.reserveBytes ?? 1, + ...(extra.maxChunkBytes !== undefined ? { maxChunkBytes: extra.maxChunkBytes } : {}), + ...(extra.maxTaskBytes !== undefined ? { maxTaskBytes: extra.maxTaskBytes } : {}), + ...(extra.streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs: extra.streamIdleTimeoutMs } : {}), + }, + }); + + beforeEach(async () => { + tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cindy-library-staging-')); + scope = 'local:owner-a:1'; + }); + afterEach(async () => { + vi.restoreAllMocks(); + await fs.promises.rm(tmp, { recursive: true, force: true }); + }); + + async function beginChunk( + store: LibraryStagingStore, + taskId: string, + payload: string | Buffer, + sourceRevision = 'rev-1', + ) { + const buf = typeof payload === 'string' ? Buffer.from(payload) : payload; + const digest = sha256Of(buf); + const begin = await store.begin({ + ghostId, taskId, sourceRevision, + totalBytes: buf.byteLength, sha256: digest, mime: 'image/png', recovery, + }); + if (!begin.ok) throw new Error(`begin ${taskId}: ${JSON.stringify(begin)}`); + const chunk = await store.chunk({ + ghostId, stagingId: begin.stagingId, seq: 1, + content: buf.toString('base64'), encoding: 'base64', + }); + if (!chunk.ok) throw new Error(`chunk ${taskId}: ${JSON.stringify(chunk)}`); + return { stagingId: begin.stagingId, digest, bytes: buf.byteLength }; + } + + async function commitOne( + store = makeStore(), + taskId = 'task-1', + payload: string | Buffer = body, + sourceRevision = 'rev-1', + ) { + const started = await beginChunk(store, taskId, payload, sourceRevision); + const commit = await store.commit({ ghostId, stagingId: started.stagingId }); + if (!commit.ok) throw new Error(`commit ${taskId}: ${JSON.stringify(commit)}`); + return { store, stagingId: started.stagingId, commit, digest: started.digest }; + } + + function blobAbs(root: string, stagingId: string): string { + return path.join(root, 'tasks', stagingId, 'blob.bin'); + } + function manifestAbs(root: string, stagingId: string): string { + return path.join(root, 'tasks', stagingId, 'manifest.json'); + } + function tombstoneAbs(root: string, stagingId: string): string { + return path.join(root, 'tasks', stagingId, 'tombstone.json'); + } + function intentAbs(root: string, stagingId: string): string { + return path.join(root, 'tasks', stagingId, 'intent.json'); + } + function matchingAck(commit: { sha256: string; bytes: number }) { + return { + ok: true as const, + path: `assets/${commit.sha256.slice(0, 2)}/${commit.sha256}/blob.png`, + sha256: commit.sha256, + bytes: commit.bytes, + libraryIdentity: HEX_A, + libraryGeneration: 0, + }; + } + + async function listAllPublic(store: LibraryStagingStore, limit = 2) { + const items: Array<{ stagingId: string; bytes: number; taskId: string }> = []; + let cursor: string | undefined; + for (;;) { + const page = await store.list({ ghostId, cursor, limit }); + if (!page.ok) throw new Error(`list: ${JSON.stringify(page)}`); + items.push(...page.items.map((item) => ({ + stagingId: item.stagingId, bytes: item.bytes, taskId: item.taskId, + }))); + if (!page.hasMore) { + expect(page.nextCursor).toBeNull(); + return { items, last: page }; + } + expect(typeof page.nextCursor).toBe('string'); + cursor = page.nextCursor ?? undefined; + } + } + + it('commit 后新实例 list/read 可恢复;未提交流不出现', async () => { + const root = path.join(tmp, 'library-staging', ghostId); + const live = makeStore(root); + const uploading = await live.begin({ + ghostId, taskId: 'partial', sourceRevision: 'r', + totalBytes: 8, sha256: '0'.repeat(64), mime: 'image/png', recovery, + }); + if (!uploading.ok) throw new Error(JSON.stringify(uploading)); + const { stagingId } = await commitOne(live); + const restored = makeStore(root); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([stagingId]); + expect(listed.items[0]?.durable).toBe(true); + expect(listed.items[0]?.recovery).toEqual(recovery); + const read = await restored.read({ ghostId, stagingId }); + if (!read.ok) throw new Error(JSON.stringify(read)); + expect(Buffer.from(read.content, 'base64').toString('utf8')).toBe(body); + }); + + it('坏 manifest:list 失败非空,原件保留;修复后同一实例 list/read 成功且无重复', async () => { + const root = path.join(tmp, 'library-staging', ghostId); + const { stagingId: firstId } = await commitOne(makeStore(root, { maxTotalBytes: 1024 }), 'task-a', 'alpha'); + const { stagingId: secondId } = await commitOne(makeStore(root, { maxTotalBytes: 1024 }), 'task-b', 'bravo'); + const originalManifest = await fs.promises.readFile(manifestAbs(root, secondId), 'utf8'); + await fs.promises.writeFile(manifestAbs(root, secondId), '{not json'); + + const same = makeStore(root, { maxTotalBytes: 1024 }); + const listed = await same.list({ ghostId }); + expect(listed).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(blobAbs(root, firstId))).toBe(true); + expect(fs.existsSync(blobAbs(root, secondId))).toBe(true); + const blocked = await same.begin({ + ghostId, taskId: 'task-x', sourceRevision: 'rev-x', + totalBytes: 1, sha256: '0'.repeat(64), mime: 'image/png', recovery, + }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(blocked.ok).toBe(false); + + await fs.promises.writeFile(manifestAbs(root, secondId), originalManifest); + const recovered = await same.list({ ghostId }); + if (!recovered.ok) throw new Error(`same-instance recover: ${JSON.stringify(recovered)}`); + const ids = recovered.items.map((item) => item.stagingId).sort(); + expect(ids).toEqual([firstId, secondId].sort()); + expect(new Set(ids).size).toBe(2); + const readFirst = await same.read({ ghostId, stagingId: firstId }); + const readSecond = await same.read({ ghostId, stagingId: secondId }); + if (!readFirst.ok || !readSecond.ok) throw new Error('same-instance read failed'); + expect(Buffer.from(readFirst.content, 'base64').toString('utf8')).toBe('alpha'); + expect(Buffer.from(readSecond.content, 'base64').toString('utf8')).toBe('bravo'); + }); + + it('不可读 manifest(目录占位)同样 LIBRARY_UNAVAILABLE,修复后同一实例恢复', async () => { + const root = path.join(tmp, 'library-staging', ghostId); + const { stagingId } = await commitOne(makeStore(root)); + const original = await fs.promises.readFile(manifestAbs(root, stagingId), 'utf8'); + await fs.promises.rm(manifestAbs(root, stagingId)); + await fs.promises.mkdir(manifestAbs(root, stagingId)); + + const same = makeStore(root); + expect(await same.list({ ghostId })).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + + await fs.promises.rm(manifestAbs(root, stagingId), { recursive: true, force: true }); + await fs.promises.writeFile(manifestAbs(root, stagingId), original); + const listed = await same.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([stagingId]); + const read = await same.read({ ghostId, stagingId }); + if (!read.ok) throw new Error(JSON.stringify(read)); + expect(Buffer.from(read.content, 'base64').toString('utf8')).toBe(body); + }); + + it('无 manifest 的 blob 计入额度且不列为 committed,不卡住同 task', async () => { + const root = path.join(tmp, 'library-staging', ghostId); + const orphanId = randomUUID(); + await fs.promises.mkdir(path.join(root, 'tasks', orphanId), { recursive: true }); + await fs.promises.writeFile(path.join(root, 'tasks', orphanId, 'blob.bin'), 'x'.repeat(60)); + const store = makeStore(root); + const begin = await store.begin({ + ghostId, taskId: 'task-1', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery, + }); + expect(begin).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + expect(fs.existsSync(path.join(root, 'tasks', orphanId, 'blob.bin'))).toBe(true); + const listed = await store.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items).toEqual([]); + }); + + it('活跃预留+关闭 unlink 失败残片+orphan+committed-pending+durable 合计占额,不得双计活跃 tmp', async () => { + const root = path.join(tmp, 'quota-mix', ghostId); + const orphanId = randomUUID(); + await fs.promises.mkdir(path.join(root, 'tasks', orphanId), { recursive: true }); + await fs.promises.writeFile(path.join(root, 'tasks', orphanId, 'blob.bin'), 'O'.repeat(10)); + const store = makeStore(root, { maxTotalBytes: 50, maxConcurrentWrites: 4 }); + + const durable = await commitOne(store, 'durable-task', 'D'.repeat(10)); + const small = await store.begin({ + ghostId, taskId: 'small-beside-orphan', sourceRevision: 'r', + totalBytes: 8, sha256: sha256Of('s'.repeat(8)), mime: 'image/png', recovery, + }); + if (!small.ok) throw new Error(`small begin beside orphan: ${JSON.stringify(small)}`); + const smallChunk = await store.chunk({ + ghostId, stagingId: small.stagingId, seq: 1, + content: Buffer.from('s'.repeat(8)).toString('base64'), encoding: 'base64', + }); + if (!smallChunk.ok) throw new Error(JSON.stringify(smallChunk)); + + const realUnlink = fs.promises.unlink.bind(fs.promises); + const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockImplementation(async (target, ...rest) => { + if (String(target).includes(`${path.sep}.cindy-library${path.sep}tmp${path.sep}`)) { + throw Object.assign(new Error('EACCES unlink tmp'), { code: 'EACCES' }); + } + return realUnlink(target, ...rest); + }); + const aborted = await store.abort({ ghostId, stagingId: small.stagingId }); + expect(aborted).toMatchObject({ ok: true, aborted: true }); + unlinkSpy.mockRestore(); + const tmpDir = path.join(root, '.cindy-library', 'tmp'); + const leftover = (await fs.promises.readdir(tmpDir)).filter((name) => name !== '.' && name !== '..'); + expect(leftover.length).toBeGreaterThan(0); + const leftoverBytes = leftover.reduce((sum, name) => { + const st = fs.statSync(path.join(tmpDir, name)); + return sum + (st.isFile() ? st.size : 0); + }, 0); + expect(leftoverBytes).toBeGreaterThan(0); + + const active = await store.begin({ + ghostId, taskId: 'still-active', sourceRevision: 'r', + totalBytes: 5, sha256: sha256Of('A'.repeat(5)), mime: 'image/png', recovery, + }); + if (!active.ok) throw new Error(`active reservation: ${JSON.stringify(active)}`); + + const origWrite = LibraryVault.prototype.write; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest write failed' }; + } + return origWrite.call(this, req); + }); + let pendingId: string | undefined; + try { + const pending = await beginChunk(store, 'pending-task', 'P'.repeat(10)); + pendingId = pending.stagingId; + const failedCommit = await store.commit({ ghostId, stagingId: pending.stagingId }); + expect(failedCommit.ok).toBe(false); + expect(fs.existsSync(blobAbs(root, pending.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, pending.stagingId))).toBe(false); + + // durable 10 + orphan 10 + closed residue >=8 + active 5 + pending 10 >= 43; +8 exceeds 50. + const extra = await store.begin({ + ghostId, taskId: 'extra-over-quota', sourceRevision: 'rev-1', + totalBytes: 8, sha256: sha256Of('E'.repeat(8)), mime: 'image/png', recovery, + }); + expect(extra).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + } finally { + writeSpy.mockRestore(); + } + + if (!pendingId) throw new Error('pending stagingId missing'); + const chunkSpy = vi.spyOn(LibraryVault.prototype, 'writeChunk'); + const beginSpy = vi.spyOn(LibraryVault.prototype, 'writeBegin'); + try { + const retried = await store.commit({ ghostId, stagingId: pendingId }); + if (!retried.ok) throw new Error(`retry commit after manifest fault removed: ${JSON.stringify(retried)}`); + expect(retried.durable).toBe(true); + expect(retried.bytes).toBe(10); + expect(chunkSpy).not.toHaveBeenCalled(); + expect(beginSpy).not.toHaveBeenCalled(); + } finally { + chunkSpy.mockRestore(); + beginSpy.mockRestore(); + } + expect(fs.existsSync(manifestAbs(root, pendingId))).toBe(true); + expect(fs.existsSync(blobAbs(root, durable.stagingId))).toBe(true); + expect(fs.existsSync(path.join(root, 'tasks', orphanId, 'blob.bin'))).toBe(true); + }); + + it('abort 真正调用后关闭残片计入额度,不得与活跃预留重复计费', async () => { + const root = path.join(tmp, 'abort-residue', ghostId); + const store = makeStore(root, { maxTotalBytes: 20 }); + const begin = await store.begin({ + ghostId, taskId: 'abort-me', sourceRevision: 'r', + totalBytes: 12, sha256: sha256Of('z'.repeat(12)), mime: 'image/png', recovery, + }); + if (!begin.ok) throw new Error(JSON.stringify(begin)); + const chunked = await store.chunk({ + ghostId, stagingId: begin.stagingId, seq: 1, + content: Buffer.from('z'.repeat(12)).toString('base64'), encoding: 'base64', + }); + if (!chunked.ok) throw new Error(JSON.stringify(chunked)); + const realUnlink = fs.promises.unlink.bind(fs.promises); + const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockImplementation(async (target, ...rest) => { + if (String(target).includes(`${path.sep}.cindy-library${path.sep}tmp${path.sep}`)) { + throw Object.assign(new Error('EACCES unlink tmp'), { code: 'EACCES' }); + } + return realUnlink(target, ...rest); + }); + const aborted = await store.abort({ ghostId, stagingId: begin.stagingId }); + expect(aborted).toMatchObject({ ok: true, aborted: true }); + unlinkSpy.mockRestore(); + const tmpDir = path.join(root, '.cindy-library', 'tmp'); + const leftover = (await fs.promises.readdir(tmpDir)).filter((name) => { + const st = fs.statSync(path.join(tmpDir, name)); + return st.isFile() && st.size > 0; + }); + expect(leftover.length).toBeGreaterThan(0); + + const over = await store.begin({ + ghostId, taskId: 'task-over', sourceRevision: 'rev-1', + totalBytes: 9, sha256: sha256Of('n'.repeat(9)), mime: 'image/png', recovery, + }); + expect(over).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + + const exact = await store.begin({ + ghostId, taskId: 'task-exact', sourceRevision: 'rev-1', + totalBytes: 8, sha256: sha256Of('e'.repeat(8)), mime: 'image/png', recovery, + }); + if (!exact.ok) throw new Error(`no-double-count exact fill: ${JSON.stringify(exact)}`); + const abortExact = await store.abort({ ghostId, stagingId: exact.stagingId }); + expect(abortExact).toMatchObject({ ok: true, aborted: true }); + }); + + it('writeCommit 成功但 manifest 失败后占额;去掉故障后同一 id 提交成功且不再重传字节', async () => { + const root = path.join(tmp, 'pending-retry', ghostId); + const store = makeStore(root, { maxTotalBytes: 40 }); + const origWrite = LibraryVault.prototype.write; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest write failed' }; + } + return origWrite.call(this, req); + }); + const started = await beginChunk(store, 'pending', 'a'.repeat(20)); + const failedCommit = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(failedCommit.ok).toBe(false); + writeSpy.mockRestore(); + + const extra = await store.begin({ + ghostId, taskId: 'extra', sourceRevision: 'rev-1', + totalBytes: 21, sha256: sha256Of('b'.repeat(21)), mime: 'image/png', recovery, + }); + expect(extra).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + + const chunkSpy = vi.spyOn(LibraryVault.prototype, 'writeChunk'); + const beginSpy = vi.spyOn(LibraryVault.prototype, 'writeBegin'); + try { + const retried = await store.commit({ ghostId, stagingId: started.stagingId }); + if (!retried.ok) throw new Error(`retry commit: ${JSON.stringify(retried)}`); + expect(retried.durable).toBe(true); + expect(chunkSpy).not.toHaveBeenCalled(); + expect(beginSpy).not.toHaveBeenCalled(); + } finally { + chunkSpy.mockRestore(); + beginSpy.mockRestore(); + } + const listed = await store.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([started.stagingId]); + }); + + it('任务目录、tasks 父目录、vault 根 fsync 失败均真正命中对应方法:初次不得 durable,去掉故障后同 id 提交成功且新 Store 可 list/read', async () => { + const cases: Array<{ + label: string; + failWhen: (relPath: unknown, stagingId: string) => boolean; + }> = [ + { + label: 'task-dir', + failWhen: (relPath, stagingId) => relPath === `tasks/${stagingId}`, + }, + { + label: 'tasks-parent', + failWhen: (relPath) => relPath === 'tasks', + }, + { + label: 'vault-root', + failWhen: (relPath) => relPath === '', + }, + ]; + + for (const item of cases) { + const root = path.join(tmp, `fsync-${item.label}`, ghostId); + const store = makeStore(root, { maxTotalBytes: 1024 }); + const started = await beginChunk(store, `fsync-${item.label}`, body); + const seen: unknown[] = []; + const orig = LibraryVault.prototype.fsyncDir; + const spy = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + seen.push(relPath); + if (item.failWhen(relPath, started.stagingId)) { + return { ok: false, errorCode: 'INTERNAL', message: `${item.label} fsync 失败` }; + } + return orig.call(this, relPath); + }); + try { + const commit = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(commit.ok, `${item.label} 初次 commit 应失败`).toBe(false); + if (!commit.ok) expect(commit.errorCode).toBe('INTERNAL'); + expect(commit).not.toHaveProperty('durable'); + expect(seen.some((rel) => item.failWhen(rel, started.stagingId)), `${item.label} 未命中 fsyncDir`).toBe(true); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + const listed = await store.list({ ghostId }); + if (listed.ok) { + expect(listed.items.every((row) => row.stagingId !== started.stagingId)).toBe(true); + } + } finally { + spy.mockRestore(); + } + + const retried = await store.commit({ ghostId, stagingId: started.stagingId }); + if (!retried.ok) throw new Error(`${item.label} retry commit: ${JSON.stringify(retried)}`); + expect(retried.durable).toBe(true); + const restored = makeStore(root, { maxTotalBytes: 1024 }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(`${item.label} new store list: ${JSON.stringify(listed)}`); + expect(listed.items.map((row) => row.stagingId)).toEqual([started.stagingId]); + const read = await restored.read({ ghostId, stagingId: started.stagingId }); + if (!read.ok) throw new Error(`${item.label} new store read: ${JSON.stringify(read)}`); + expect(Buffer.from(read.content, 'base64').toString('utf8')).toBe(body); + } + }); + + it('release:错误 ACK 保留原件;tombstone fsync 失败保留原件;manifest 删除失败后同实例与新 Store 收敛;损坏 tombstone 不得静默删原件;成功释放精确还额', async () => { + const root = path.join(tmp, 'release', ghostId); + const store = makeStore(root, { maxTotalBytes: 64 }); + const { stagingId, commit } = await commitOne(store); + + const mismatch = await store.release({ + ghostId, stagingId, + ack: { + ok: true, + path: 'assets/aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/blob.png', + sha256: '1'.repeat(64), bytes: 1, libraryIdentity: HEX_A, libraryGeneration: 0, + }, + }); + expect(mismatch).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + const still = await store.list({ ghostId }); + if (!still.ok) throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(stagingId); + + const origFsync = LibraryVault.prototype.fsyncDir; + const tombFsync = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + if (typeof relPath === 'string' && relPath === `tasks/${stagingId}` && fs.existsSync(tombstoneAbs(root, stagingId))) { + return { ok: false, errorCode: 'INTERNAL', message: 'tombstone fsync 失败' }; + } + return origFsync.call(this, relPath); + }); + try { + const blocked = await store.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(blocked.ok).toBe(false); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + } finally { + tombFsync.mockRestore(); + } + + const origDelete = LibraryVault.prototype.delete; + let blobDeleted = false; + const deleteSpy = vi.spyOn(LibraryVault.prototype, 'delete').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json') && blobDeleted) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest delete failed' }; + } + const result = await origDelete.call(this, req); + if (result.ok && typeof req.path === 'string' && req.path.endsWith('blob.bin')) blobDeleted = true; + return result; + }); + try { + const failed = await store.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(failed.ok).toBe(false); + const retrySame = await store.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(retrySame.ok).toBe(false); + const midFault = makeStore(root, { maxTotalBytes: 64 }); + const midListed = await midFault.list({ ghostId }); + expect(midListed.ok).toBe(false); + if (!midListed.ok) expect(['LIBRARY_UNAVAILABLE', 'INTERNAL']).toContain(midListed.errorCode); + const occupied = await store.begin({ + ghostId, taskId: 'quota-while-cleanup-fails', sourceRevision: 'r', + totalBytes: 55, sha256: sha256Of('q'.repeat(55)), mime: 'image/png', recovery, + }); + expect(occupied).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + } finally { + deleteSpy.mockRestore(); + } + + const converged = await store.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(converged).toEqual({ ok: true, stagingId, released: true }); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(false); + + const restored = makeStore(root, { maxTotalBytes: 64 }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(`new store after release: ${JSON.stringify(listed)}`); + expect(listed.items).toEqual([]); + const again = await restored.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(again).toMatchObject({ ok: true, released: false }); + + const { stagingId: liveId, commit: liveCommit } = await commitOne(restored, 'task-live', 'keep-me'); + await fs.promises.writeFile(tombstoneAbs(root, liveId), '{not a tombstone'); + const corruptStore = makeStore(root, { maxTotalBytes: 64 }); + const corruptList = await corruptStore.list({ ghostId }); + expect(corruptList).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(blobAbs(root, liveId))).toBe(true); + await fs.promises.rm(tombstoneAbs(root, liveId), { force: true }); + const repaired = await corruptStore.list({ ghostId }); + if (!repaired.ok) throw new Error(`tombstone repaired: ${JSON.stringify(repaired)}`); + expect(repaired.items.map((item) => item.stagingId)).toEqual([liveId]); + const liveRead = await corruptStore.read({ ghostId, stagingId: liveId }); + if (!liveRead.ok) throw new Error(JSON.stringify(liveRead)); + expect(Buffer.from(liveRead.content, 'base64').toString('utf8')).toBe('keep-me'); + + const released = await corruptStore.release({ ghostId, stagingId: liveId, ack: matchingAck(liveCommit) }); + expect(released).toEqual({ ok: true, stagingId: liveId, released: true }); + const afterRelease = await corruptStore.begin({ + ghostId, taskId: 'after-release', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery, + }); + expect(afterRelease.ok).toBe(true); + }); + + it('有效 tombstone+残留 blob/manifest:新 Store 成功清理后立即归还全部 bytes,不得把已删除 leftover 计入 orphan', async () => { + const root = path.join(tmp, 'tombstone-quota', ghostId); + const maxTotalBytes = 40; + const payload = 'T'.repeat(maxTotalBytes); + const writer = makeStore(root, { maxTotalBytes }); + const { stagingId } = await commitOne(writer, 'crash-release', payload); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, stagingId))).toBe(true); + await fs.promises.writeFile( + tombstoneAbs(root, stagingId), + JSON.stringify({ version: 1, stagingId, released: true }), + ); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, stagingId))).toBe(true); + expect(fs.existsSync(tombstoneAbs(root, stagingId))).toBe(true); + + const restored = makeStore(root, { maxTotalBytes }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(`loadJournal after valid tombstone: ${JSON.stringify(listed)}`); + expect(listed.items).toEqual([]); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(false); + expect(fs.existsSync(manifestAbs(root, stagingId))).toBe(false); + expect(fs.existsSync(tombstoneAbs(root, stagingId))).toBe(false); + + const reclaimed = await restored.begin({ + ghostId, taskId: 'reclaimed-after-tombstone-cleanup', sourceRevision: 'rev-1', + totalBytes: maxTotalBytes, sha256: sha256Of(payload), mime: 'image/png', recovery, + }); + expect(reclaimed, `quota not returned after successful tombstone cleanup: ${JSON.stringify(reclaimed)}`).toMatchObject({ ok: true }); + if (!reclaimed.ok) return; + const aborted = await restored.abort({ ghostId, stagingId: reclaimed.stagingId }); + expect(aborted).toMatchObject({ ok: true, aborted: true }); + }); + + it('manifest 写失败后 commitPending blob 已落地:拒 abort 且不调 writeAbort,超额 begin 必须 STAGING_QUOTA,同 id commit 重试成功', async () => { + const root = path.join(tmp, 'commit-pending-abort', ghostId); + const maxTotalBytes = 40; + const store = makeStore(root, { maxTotalBytes }); + const started = await beginChunk(store, 'pending-abort', 'a'.repeat(20)); + const origWrite = LibraryVault.prototype.write; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest write failed' }; + } + return origWrite.call(this, req); + }); + const failedCommit = await store.commit({ ghostId, stagingId: started.stagingId }); + writeSpy.mockRestore(); + expect(failedCommit.ok).toBe(false); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, started.stagingId))).toBe(false); + + const writeAbort = vi.spyOn(LibraryVault.prototype, 'writeAbort'); + const aborted = await store.abort({ ghostId, stagingId: started.stagingId }); + expect(writeAbort).not.toHaveBeenCalled(); + writeAbort.mockRestore(); + expect(aborted).toMatchObject({ ok: false, errorCode: 'STREAM_INVALID' }); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, started.stagingId))).toBe(false); + + const over = await store.begin({ + ghostId, taskId: 'over-after-pending-abort', sourceRevision: 'rev-1', + totalBytes: 21, sha256: sha256Of('b'.repeat(21)), mime: 'image/png', recovery, + }); + expect(over, `abort dropped undeleted blob from quota: ${JSON.stringify(over)}`).toMatchObject({ + ok: false, errorCode: 'STAGING_QUOTA', + }); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + + const retried = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(retried).toMatchObject({ ok: true, stagingId: started.stagingId, durable: true, bytes: 20 }); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, started.stagingId))).toBe(true); + }); + + it('abort 不得删除已提交原件;不存在 TTL 清掉 unique durable', async () => { + const root = path.join(tmp, 'no-ttl', ghostId); + const store = makeStore(root, { maxTotalBytes: 1024 }); + const { stagingId } = await commitOne(store); + const abortDurable = await store.abort({ ghostId, stagingId }); + expect(abortDurable).toMatchObject({ ok: false, errorCode: 'STREAM_INVALID' }); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + + const other = await beginChunk(store, 'other', 'other-bytes'); + const abortedOther = await store.abort({ ghostId, stagingId: other.stagingId }); + expect(abortedOther).toMatchObject({ ok: true, aborted: true }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const listed = await store.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([stagingId]); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + }); + + it('超过 Vault.listPageSize 的恢复与公开 list 游标:每条 durable 恰好一次且跨页合计字节', async () => { + const pageSize = 2; + expect(pageSize).toBeLessThan(DEFAULT_LIBRARY_LIMITS.listPageSize); + const root = path.join(tmp, 'pages', ghostId); + const writer = makeStore(root, { maxTotalBytes: 4096, listPageSize: pageSize }); + const expected = new Map(); + for (let i = 0; i < pageSize + 3; i += 1) { + const payload = `page-item-${i}`; + const committed = await commitOne(writer, `task-${i}`, payload, `rev-${i}`); + expected.set(committed.stagingId, Buffer.byteLength(payload)); + } + expect(expected.size).toBeGreaterThan(DEFAULT_LIBRARY_LIMITS.listPageSize > pageSize ? pageSize : 0); + expect(expected.size).toBeGreaterThan(pageSize); + + const vaultList = vi.spyOn(LibraryVault.prototype, 'list'); + const restored = makeStore(root, { maxTotalBytes: 4096, listPageSize: pageSize }); + const walked = await listAllPublic(restored, pageSize); + const taskDirPages = vaultList.mock.calls.filter((args) => args[0]?.path === 'tasks').length; + vaultList.mockRestore(); + expect(taskDirPages).toBeGreaterThan(1); + + const seen = walked.items.map((item) => item.stagingId); + expect(seen.sort()).toEqual([...expected.keys()].sort()); + expect(new Set(seen).size).toBe(expected.size); + const totalBytes = walked.items.reduce((sum, item) => sum + item.bytes, 0); + const expectedBytes = [...expected.values()].reduce((sum, n) => sum + n, 0); + expect(totalBytes).toBe(expectedBytes); + expect(walked.items.length).toBeGreaterThan(pageSize); + }); + + it('成功恢复后多次 list/read/chunk 不再对已保留原件全量 hash', async () => { + const root = path.join(tmp, 'no-rehash', ghostId); + const { stagingId } = await commitOne(makeStore(root, { maxTotalBytes: 1024 }), 'kept', 'kept-bytes'); + const origHash = LibraryVault.prototype.hashFile; + const hashSpy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(function (this: LibraryVault, relPath: string) { + return origHash.call(this, relPath); + }); + const restored = makeStore(root, { maxTotalBytes: 1024 }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([stagingId]); + const hashesDuringRecovery = hashSpy.mock.calls.filter((args) => args[0] === `tasks/${stagingId}/blob.bin`).length; + expect(hashesDuringRecovery).toBeGreaterThan(0); + hashSpy.mockClear(); + + const listedAgain = await restored.list({ ghostId }); + if (!listedAgain.ok) throw new Error(JSON.stringify(listedAgain)); + const read1 = await restored.read({ ghostId, stagingId }); + const read2 = await restored.read({ ghostId, stagingId, offset: 0, length: 4 }); + if (!read1.ok || !read2.ok) throw new Error('read after recovery failed'); + const extra = await beginChunk(restored, 'extra-after-recovery', 'xy'); + const extraChunk = await restored.chunk({ + ghostId, stagingId: extra.stagingId, seq: 1, + content: Buffer.from('xy').toString('base64'), encoding: 'base64', + }); + expect(extraChunk.ok).toBe(true); + const retainedHashCalls = hashSpy.mock.calls.filter((args) => args[0] === `tasks/${stagingId}/blob.bin`); + expect(retainedHashCalls).toEqual([]); + }); + + it('省略 length 的 >16MiB 原件返回 <=maxChunkBytes 前缀;显式分段正确;非法 offset/length 为 PATH_INVALID', async () => { + const root = path.join(tmp, 'read-16m', ghostId); + const store = makeStore(root, { maxTotalBytes: 32 * 1024 * 1024 }); + const payload = Buffer.alloc(DEFAULT_READ_CHUNK + 64, 7); + payload[0] = 11; + payload[DEFAULT_READ_CHUNK] = 22; + payload[payload.byteLength - 1] = 33; + const digest = sha256Of(payload); + const begin = await store.begin({ + ghostId, taskId: 'big-image', sourceRevision: 'rev-1', + totalBytes: payload.byteLength, sha256: digest, mime: 'image/png', recovery, + }); + if (!begin.ok) throw new Error(JSON.stringify(begin)); + const first = payload.subarray(0, DEFAULT_READ_CHUNK); + const rest = payload.subarray(DEFAULT_READ_CHUNK); + const chunk1 = await store.chunk({ + ghostId, stagingId: begin.stagingId, seq: 1, + content: first.toString('base64'), encoding: 'base64', + }); + if (!chunk1.ok) throw new Error(JSON.stringify(chunk1)); + const chunk2 = await store.chunk({ + ghostId, stagingId: begin.stagingId, seq: 2, + content: rest.toString('base64'), encoding: 'base64', + }); + if (!chunk2.ok) throw new Error(JSON.stringify(chunk2)); + const commit = await store.commit({ ghostId, stagingId: begin.stagingId }); + if (!commit.ok) throw new Error(JSON.stringify(commit)); + + const omitted = await store.read({ ghostId, stagingId: begin.stagingId }); + if (!omitted.ok) throw new Error(JSON.stringify(omitted)); + expect(omitted.bytes).toBe(DEFAULT_READ_CHUNK); + expect(omitted.bytes).toBeLessThanOrEqual(DEFAULT_READ_CHUNK); + expect(Buffer.from(omitted.content, 'base64').equals(first)).toBe(true); + expect(omitted.sha256).toBe(sha256Of(first)); + + const tail = await store.read({ + ghostId, stagingId: begin.stagingId, offset: DEFAULT_READ_CHUNK, length: 64, + }); + if (!tail.ok) throw new Error(JSON.stringify(tail)); + expect(tail.bytes).toBe(64); + expect(Buffer.from(tail.content, 'base64').equals(rest)).toBe(true); + expect(tail.sha256).toBe(sha256Of(rest)); + + const mid = await store.read({ ghostId, stagingId: begin.stagingId, offset: 1, length: 3 }); + if (!mid.ok) throw new Error(JSON.stringify(mid)); + expect(Buffer.from(mid.content, 'base64').equals(payload.subarray(1, 4))).toBe(true); + + const smallRoot = path.join(tmp, 'read-small', ghostId); + const small = makeStore(smallRoot, { maxTotalBytes: 1024 }); + const { stagingId } = await commitOne(small, 'small-1', 'abcdefghijklmnopqrst'); + const whole = await small.read({ ghostId, stagingId }); + if (!whole.ok) throw new Error(JSON.stringify(whole)); + expect(whole.bytes).toBe(Buffer.byteLength('abcdefghijklmnopqrst')); + + expect(await small.read({ ghostId, stagingId, offset: -1 })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, length: 1.5 })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, offset: Number.NaN })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, length: Number.POSITIVE_INFINITY })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, offset: Number.NEGATIVE_INFINITY })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, length: DEFAULT_READ_CHUNK + 1 })).toMatchObject({ ok: false, errorCode: 'TOO_LARGE' }); + }, 60_000); + + it('同一 task/revision/metadata 在活跃上传与 commit 后 begin 幂等;末块精确重复接受、冲突重复拒绝', async () => { + const store = makeStore(path.join(tmp, 'idempotent', ghostId), { maxTotalBytes: 1024, maxConcurrentWrites: 2 }); + const payload = 'abcd'; + const digest = sha256Of(payload); + const first = await store.begin({ + ghostId, taskId: 'same-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: digest, mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const againActive = await store.begin({ + ghostId, taskId: 'same-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: digest, mime: 'image/png', recovery, + }); + expect(againActive).toEqual({ ok: true, stagingId: first.stagingId }); + const other = await store.begin({ + ghostId, taskId: 'other-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: sha256Of('wxyz'), mime: 'image/png', recovery, + }); + expect(other.ok).toBe(true); + + const chunk = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + expect(chunk).toMatchObject({ ok: true, accepted: 4 }); + const dup = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + expect(dup).toMatchObject({ ok: true, accepted: 4 }); + const conflict = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from('abce').toString('base64'), encoding: 'base64', + }); + expect(conflict).toMatchObject({ ok: false, errorCode: 'STREAM_INVALID' }); + const gap = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 3, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + expect(gap).toMatchObject({ ok: false, errorCode: 'STREAM_INVALID' }); + + const commit = await store.commit({ ghostId, stagingId: first.stagingId }); + if (!commit.ok) throw new Error(JSON.stringify(commit)); + const againCommitted = await store.begin({ + ghostId, taskId: 'same-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: digest, mime: 'image/png', recovery, + }); + expect(againCommitted).toEqual({ ok: true, stagingId: first.stagingId }); + const conflictingMeta = await store.begin({ + ghostId, taskId: 'same-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: sha256Of('abce'), mime: 'image/png', recovery, + }); + expect(conflictingMeta).toMatchObject({ ok: false, errorCode: 'ALREADY_EXISTS' }); + }); + + it('null owner 与切账号后不得写新文件或读旧数据', async () => { + const { store, stagingId } = await commitOne(); + scope = null; + const listed = await store.list({ ghostId }); + expect(listed).toMatchObject({ ok: false, errorCode: 'OWNER_CHANGED' }); + scope = 'local:owner-b:1'; + const crossed = await store.read({ ghostId, stagingId }); + expect(crossed).toMatchObject({ ok: false, errorCode: 'OWNER_CHANGED' }); + }); + + it('manifest 已落盘且 journalSync/delete 失败后,同实例重试读回身份并 durable,冲突 manifest 拒绝覆盖', async () => { + const root = path.join(tmp, 'retained-manifest', ghostId); + const store = makeStore(root, { maxTotalBytes: 1024 }); + const started = await beginChunk(store, 'retained', body); + expect(fs.existsSync(intentAbs(root, started.stagingId))).toBe(true); + const origWrite = LibraryVault.prototype.write; + const origFsync = LibraryVault.prototype.fsyncDir; + const origDelete = LibraryVault.prototype.delete; + let failJournal = false; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + const result = await origWrite.call(this, req); + if (typeof req.path === 'string' && req.path.endsWith('manifest.json') && result.ok) failJournal = true; + return result; + }); + const fsyncSpy = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + if (failJournal) return { ok: false, errorCode: 'INTERNAL', message: 'journal fsync 失败' }; + return origFsync.call(this, relPath); + }); + const deleteSpy = vi.spyOn(LibraryVault.prototype, 'delete').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest delete failed' }; + } + return origDelete.call(this, req); + }); + try { + const failed = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.errorCode).toBe('INTERNAL'); + expect(fs.existsSync(manifestAbs(root, started.stagingId))).toBe(true); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + } finally { + writeSpy.mockRestore(); + fsyncSpy.mockRestore(); + deleteSpy.mockRestore(); + } + const retried = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(retried).toMatchObject({ ok: true, stagingId: started.stagingId, durable: true, bytes: Buffer.byteLength(body) }); + const listed = await store.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([started.stagingId]); + + const conflictRoot = path.join(tmp, 'conflict-manifest', ghostId); + const conflictStore = makeStore(conflictRoot, { maxTotalBytes: 1024 }); + const other = await beginChunk(conflictStore, 'conflict', body); + const origFsync2 = LibraryVault.prototype.fsyncDir; + const plant = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + if (relPath === `tasks/${other.stagingId}` && !fs.existsSync(manifestAbs(conflictRoot, other.stagingId))) { + const r = await origFsync2.call(this, relPath); + await fs.promises.writeFile(manifestAbs(conflictRoot, other.stagingId), JSON.stringify({ + version: 1, durable: true, intent: false, + stagingId: other.stagingId, ghostId, ownerScopeKey: 'local:owner-a:1', + taskId: 'not-this-task', sourceRevision: 'rev-x', + sha256: other.digest, bytes: other.bytes, mime: 'image/png', recovery, + })); + return r; + } + return origFsync2.call(this, relPath); + }); + try { + const conflicted = await conflictStore.commit({ ghostId, stagingId: other.stagingId }); + expect(conflicted).toMatchObject({ ok: false, errorCode: 'ALREADY_EXISTS' }); + const onDisk = JSON.parse(await fs.promises.readFile(manifestAbs(conflictRoot, other.stagingId), 'utf8')) as { taskId: string }; + expect(onDisk.taskId).toBe('not-this-task'); + expect(fs.existsSync(blobAbs(conflictRoot, other.stagingId))).toBe(true); + } finally { + plant.mockRestore(); + } + }); + + it('intent 在 blob 就位前 fsync 失败则回滚;intent+blob 重启后恢复;冲突/不完整 fail-closed 保留源;release 清 intent;无配额泄漏', async () => { + const fsyncRoot = path.join(tmp, 'intent-fsync', ghostId); + const fsyncStore = makeStore(fsyncRoot, { maxTotalBytes: 1024 }); + const origFsync = LibraryVault.prototype.fsyncDir; + const fsyncSpy = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + if (relPath === 'tasks' || relPath === '') { + return { ok: false, errorCode: 'INTERNAL', message: 'intent parent fsync 失败' }; + } + return origFsync.call(this, relPath); + }); + try { + const began = await fsyncStore.begin({ + ghostId, taskId: 'intent-fsync', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery, + }); + expect(began.ok).toBe(false); + if (!began.ok) expect(began.errorCode).toBe('INTERNAL'); + } finally { + fsyncSpy.mockRestore(); + } + const taskDirs = fs.existsSync(path.join(fsyncRoot, 'tasks')) + ? await fs.promises.readdir(path.join(fsyncRoot, 'tasks')) + : []; + for (const id of taskDirs) { + expect(fs.existsSync(intentAbs(fsyncRoot, id))).toBe(false); + expect(fs.existsSync(blobAbs(fsyncRoot, id))).toBe(false); + } + + const recoverRoot = path.join(tmp, 'intent-recover', ghostId); + const live = makeStore(recoverRoot, { maxTotalBytes: 20 }); + const started = await beginChunk(live, 'intent-crash', 'x'.repeat(20)); + expect(fs.existsSync(intentAbs(recoverRoot, started.stagingId))).toBe(true); + const origWrite = LibraryVault.prototype.write; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest write failed' }; + } + return origWrite.call(this, req); + }); + try { + const failed = await live.commit({ ghostId, stagingId: started.stagingId }); + expect(failed.ok).toBe(false); + expect(fs.existsSync(blobAbs(recoverRoot, started.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(recoverRoot, started.stagingId))).toBe(false); + expect(fs.existsSync(intentAbs(recoverRoot, started.stagingId))).toBe(true); + } finally { + writeSpy.mockRestore(); + } + const restored = makeStore(recoverRoot, { maxTotalBytes: 20 }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(`intent recover list: ${JSON.stringify(listed)}`); + expect(listed.items.map((item) => item.stagingId)).toEqual([started.stagingId]); + expect(listed.items[0]?.durable).toBe(true); + expect(fs.existsSync(manifestAbs(recoverRoot, started.stagingId))).toBe(true); + const read = await restored.read({ ghostId, stagingId: started.stagingId }); + if (!read.ok) throw new Error(JSON.stringify(read)); + expect(Buffer.from(read.content, 'base64').toString('utf8')).toBe('x'.repeat(20)); + const leaked = await restored.begin({ + ghostId, taskId: 'should-quota', sourceRevision: 'rev-1', + totalBytes: 1, sha256: sha256Of('y'), mime: 'image/png', recovery, + }); + expect(leaked).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + const released = await restored.release({ + ghostId, stagingId: started.stagingId, ack: matchingAck({ sha256: started.digest, bytes: 20 }), + }); + expect(released).toEqual({ ok: true, stagingId: started.stagingId, released: true }); + expect(fs.existsSync(intentAbs(recoverRoot, started.stagingId))).toBe(false); + expect(fs.existsSync(blobAbs(recoverRoot, started.stagingId))).toBe(false); + + const conflictId = randomUUID(); + const conflictRoot = path.join(tmp, 'intent-conflict', ghostId); + await fs.promises.mkdir(path.join(conflictRoot, 'tasks', conflictId), { recursive: true }); + await fs.promises.writeFile(path.join(conflictRoot, 'tasks', conflictId, 'blob.bin'), body); + await fs.promises.writeFile(path.join(conflictRoot, 'tasks', conflictId, 'intent.json'), JSON.stringify({ + version: 1, intent: true, + stagingId: conflictId, ghostId, ownerScopeKey: 'local:owner-a:1', + taskId: 'guessed', sourceRevision: 'rev-1', + sha256: '0'.repeat(64), bytes: Buffer.byteLength(body), mime: 'image/png', recovery, + })); + const conflicted = makeStore(conflictRoot, { maxTotalBytes: 1024 }); + const conflictList = await conflicted.list({ ghostId }); + expect(conflictList).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(blobAbs(conflictRoot, conflictId))).toBe(true); + }); + + it('闲置未完成上传超时后让出并发槽,不扫 commitPending/durable', async () => { + let now = 1_000; + const store = makeStore(path.join(tmp, 'idle-busy', ghostId), { + maxConcurrentWrites: 1, + maxTotalBytes: 1024, + streamIdleTimeoutMs: 10, + now: () => now, + }); + const first = await store.begin({ + ghostId, taskId: 'idle-one', sourceRevision: 'r', + totalBytes: 4, sha256: sha256Of('idle'), mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const busy = await store.begin({ + ghostId, taskId: 'blocked', sourceRevision: 'r', + totalBytes: 4, sha256: sha256Of('next'), mime: 'image/png', recovery, + }); + expect(busy).toMatchObject({ ok: false, errorCode: 'STAGING_BUSY' }); + now = 1_020; + const second = await store.begin({ + ghostId, taskId: 'after-idle', sourceRevision: 'r', + totalBytes: 4, sha256: sha256Of('next'), mime: 'image/png', recovery, + }); + if (!second.ok) throw new Error(JSON.stringify(second)); + expect(second.stagingId).not.toBe(first.stagingId); + expect(fs.existsSync(intentAbs(path.join(tmp, 'idle-busy', ghostId), first.stagingId))).toBe(false); + expect(await store.abort({ ghostId, stagingId: second.stagingId })).toMatchObject({ ok: true, aborted: true }); + + const durable = await commitOne(store, 'keep-durable', 'keep'); + now = 2_000; + const stillListed = await store.list({ ghostId }); + if (!stillListed.ok) throw new Error(JSON.stringify(stillListed)); + expect(stillListed.items.map((item) => item.stagingId)).toContain(durable.stagingId); + }); + + it('并发预留计入磁盘保留水位,不删已落地 commitPending 原件', async () => { + const gib = 1024 ** 3; + const store = makeStore(path.join(tmp, 'reserve', ghostId), { + maxConcurrentWrites: 2, + maxTotalBytes: 8 * gib, + maxTaskBytes: 4 * gib, + reserveBytes: gib, + getDiskFreeBytes: async () => 6 * gib, + }); + const hashA = 'a'.repeat(64); + const hashB = 'b'.repeat(64); + const first = await store.begin({ + ghostId, taskId: 'reserve-a', sourceRevision: 'r', + totalBytes: 3 * gib, sha256: hashA, mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const second = await store.begin({ + ghostId, taskId: 'reserve-b', sourceRevision: 'r', + totalBytes: 3 * gib, sha256: hashB, mime: 'image/png', recovery, + }); + expect(second).toMatchObject({ ok: false, errorCode: 'DISK_FULL' }); + const abortFirst = await store.abort({ ghostId, stagingId: first.stagingId }); + expect(abortFirst).toMatchObject({ ok: true, aborted: true }); + }); + + it('闲置清扫 unlink 失败后本次 begin 仍计入残片额度', async () => { + let now = 1_000; + const root = path.join(tmp, 'idle-residue', ghostId); + const store = makeStore(root, { + maxConcurrentWrites: 2, + maxTotalBytes: 60, + streamIdleTimeoutMs: 10, + now: () => now, + }); + const payload = 'x'.repeat(50); + const first = await store.begin({ + ghostId, taskId: 'idle-residue', sourceRevision: 'r', + totalBytes: payload.length, sha256: sha256Of(payload), mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const chunk = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + if (!chunk.ok) throw new Error(JSON.stringify(chunk)); + const realUnlink = fs.promises.unlink.bind(fs.promises); + const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockImplementation(async (target, ...rest) => { + if (String(target).includes(`${path.sep}.cindy-library${path.sep}tmp${path.sep}`)) { + throw Object.assign(new Error('EACCES unlink tmp'), { code: 'EACCES' }); + } + return realUnlink(target, ...rest); + }); + now = 1_020; + const second = await store.begin({ + ghostId, taskId: 'after-residue', sourceRevision: 'r', + totalBytes: 20, sha256: sha256Of('y'.repeat(20)), mime: 'image/png', recovery, + }); + unlinkSpy.mockRestore(); + expect(second).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + }); + + it('磁盘保留只扣未写入剩余;commitPending 不再重复占预留', async () => { + const store = makeStore(path.join(tmp, 'unwritten-reserve', ghostId), { + maxConcurrentWrites: 2, + maxTotalBytes: 10_000, + maxTaskBytes: 10_000, + maxChunkBytes: 10_000, + reserveBytes: 10, + getDiskFreeBytes: async () => 1000, + }); + const payload = 'z'.repeat(80); + const first = await store.begin({ + ghostId, taskId: 'partial', sourceRevision: 'r', + totalBytes: payload.length, sha256: sha256Of(payload), mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const chunk = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + if (!chunk.ok) throw new Error(JSON.stringify(chunk)); + const next = await store.begin({ + ghostId, taskId: 'fits-remaining', sourceRevision: 'r', + totalBytes: 960, sha256: sha256Of('n'.repeat(960)), mime: 'image/png', recovery, + }); + expect(next.ok).toBe(true); + }); + + it('同 owner 进程重启 generation 变化后仍恢复磁盘 durable,不报 manifest 字段非法', async () => { + const root = path.join(tmp, 'gen-restart', ghostId); + const first = makeStore(root, { ownerScopeKey: 'local:local-v1:2', maxTotalBytes: 1024 }); + const committed = await commitOne(first, 'restart-task', body); + const onDisk = JSON.parse(await fs.promises.readFile(manifestAbs(root, committed.stagingId), 'utf8')) as { + ownerScopeKey: string; + version: number; + durable: boolean; + }; + expect(onDisk).toMatchObject({ ownerScopeKey: 'local:local-v1:2', version: 1, durable: true }); + expect(fs.existsSync(blobAbs(root, committed.stagingId))).toBe(true); + + const restarted = makeStore(root, { ownerScopeKey: 'local:local-v1:0', maxTotalBytes: 1024 }); + const listed = await restarted.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([committed.stagingId]); + const read = await restarted.read({ ghostId, stagingId: committed.stagingId }); + if (!read.ok) throw new Error(JSON.stringify(read)); + expect(read.sha256).toBe(committed.digest); + + const otherOwner = makeStore(root, { ownerScopeKey: 'local:owner-b:0', maxTotalBytes: 1024 }); + expect(await otherOwner.list({ ghostId })).toMatchObject({ + ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: 'staging manifest 字段非法', + }); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 55ce4afe88..6ad9228aa5 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -124,6 +124,34 @@ describe('LibraryVault', () => { expect(stat.isDirectory()).toBe(true); }); + it('custom 已建过(allowCustomInit=false)且新 vault: ghost 子目录 MISSING 不得空库重建', async () => { + const parent = path.join(tmpRoot, 'picked-no-init'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(parent, { recursive: true }); + const vault = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + allowCustomInit: false, + }); + const missing = await vault.open(); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); + }); + + it('custom 已 open 后 ghost 子目录消失: 再 open 报 disk-missing 且不重建空库', async () => { + const parent = path.join(tmpRoot, 'picked-ghost-gone'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const vault = makeVault({ rootDir: () => custom, locationKind: 'custom' }); + expect(await vault.open()).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.rename(custom, `${custom}.parked`); + const missing = await vault.open(); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(parent, 'mivo-canvas', '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(`${custom}.parked`, 'keep.txt'))).toBe(true); + }); it('custom 用户父目录消失: open 报 disk-missing 且不重建空库; keep 仍在 rename 走的目录', async () => { const parent = path.join(tmpRoot, 'picked'); const custom = path.join(parent, 'mivo-canvas'); @@ -565,6 +593,73 @@ describe('LibraryVault', () => { // staging 清空。 const tmpEntries = await fs.promises.readdir(path.join(libraryRoot, '.cindy-library', 'tmp')); expect(tmpEntries).toEqual([]); + const dirSync = await vault.fsyncDir('assets'); + expect(dirSync.ok).toBe(true); + if (dirSync.ok) { + if (process.platform === 'win32') expect(dirSync.fsynced).toBe(false); + else expect(dirSync.fsynced).toBe(true); + } + const residue = await vault.tmpResidueBytes(); + expect(residue).toEqual({ ok: true, bytes: 0 }); + }); + + it('fsyncCreatedAncestors 同步新建根的父目录项,只 fsync 根不等于根 entry 已耐久', async () => { + const nestedRoot = path.join(tmpRoot, 'owners', 'a', 'library-staging', 'test-ghost'); + const vault = makeVault({ rootDir: () => nestedRoot }); + const opened = await vault.open(); + expect(opened.ok).toBe(true); + const parent = path.dirname(nestedRoot); + expect(fs.existsSync(parent)).toBe(true); + const synced = new Set(); + const origOpen = fs.promises.open.bind(fs.promises); + const spy = vi.spyOn(fs.promises, 'open').mockImplementation(async (file, flags, mode) => { + const handle = await origOpen(file, flags, mode); + if (typeof file === 'string' && flags === 'r') { + const origSync = handle.sync.bind(handle); + handle.sync = async () => { + synced.add(path.resolve(file)); + return origSync(); + }; + } + return handle; + }); + try { + const ok = await vault.fsyncCreatedAncestors(); + expect(ok.ok).toBe(true); + if (process.platform === 'win32') { + if (ok.ok) expect(ok.fsynced).toBe(false); + } else { + if (ok.ok) expect(ok.fsynced).toBe(true); + expect(synced.has(path.resolve(parent))).toBe(true); + } + } finally { + spy.mockRestore(); + } + }); + + it('fsyncCreatedAncestors 父目录 fsync 失败则 INTERNAL,不得当耐久', async () => { + const nestedRoot = path.join(tmpRoot, 'owners', 'b', 'library-staging', 'test-ghost'); + const vault = makeVault({ rootDir: () => nestedRoot }); + await vault.open(); + const parent = path.resolve(path.dirname(nestedRoot)); + const origOpen = fs.promises.open.bind(fs.promises); + const spy = vi.spyOn(fs.promises, 'open').mockImplementation(async (file, flags, mode) => { + if (typeof file === 'string' && path.resolve(file) === parent && flags === 'r') { + throw Object.assign(new Error('EIO'), { code: 'EIO' }); + } + return origOpen(file, flags, mode); + }); + try { + const failed = await vault.fsyncCreatedAncestors(); + if (process.platform === 'win32') { + expect(failed).toEqual({ ok: true, fsynced: false }); + } else { + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.errorCode).toBe('INTERNAL'); + } + } finally { + spy.mockRestore(); + } }); it('sha256 声明不符 → STREAM_INVALID 且不留目标文件', async () => { @@ -637,6 +732,8 @@ describe('LibraryVault', () => { const flat = await vault.list({ path: 'canvases/c1' }); if (flat.ok) expect(flat.entries.map((e) => e.path)).toEqual(['canvases/c1/state.json']); + const compatible = await vault.list({ recursive: false }); + expect(compatible.ok).toBe(true); }); }); @@ -771,6 +868,11 @@ describe('LibraryVault', () => { expect(r.ok).toBe(false); const d = await vault.delete({ path: 'escape-door/anything' }); expect(d.ok).toBe(false); + const compatible = await vault.list({ recursive: false }); + expect(compatible.ok).toBe(true); + const strict = await vault.list({ recursive: false, strict: true }); + expect(strict.ok).toBe(false); + if (!strict.ok) expect(strict.errorCode).toBe('LIBRARY_UNAVAILABLE'); }); }); @@ -789,6 +891,8 @@ describe('LibraryVault', () => { expect(r.sha256).toBe(sha256Of(body)); expect(r.bytes).toBe(Buffer.byteLength(body)); } + const hashed = await vault.hashFile(rel); + expect(hashed).toEqual({ ok: true, path: rel, bytes: Buffer.byteLength(body), sha256: sha256Of(body) }); }); it('打开后目标 identity 变化 → INTERNAL 且不得返回字节', async () => { diff --git a/apps/desktop/src/main/cindy-brain/forge.ts b/apps/desktop/src/main/cindy-brain/forge.ts index 54afdfd0b7..4429f24900 100644 --- a/apps/desktop/src/main/cindy-brain/forge.ts +++ b/apps/desktop/src/main/cindy-brain/forge.ts @@ -3586,7 +3586,10 @@ const st = await cindy.library({ op: 'status' }); // 只读能力查询:资格审与 op 合法性之后、会话创建之前返回;不打开库、不弹窗 const caps = await cindy.library({ op: 'capabilities' }); // caps = { ok:true, op:'capabilities', -// capabilities:{ version:1, operations:['clipboardWrite','saveAs'] } } +// capabilities:{ version:1, +// operations:['clipboardWrite','saveAs','staging.begin',...], +// staging:{ version:1, maxTaskBytes, maxTotalBytes, +// maxConcurrentWrites, maxChunkBytes, reserveBytes } } } // operations 只表示宿主实现了这些 op,不等于此刻有窗口 / 已授权 / 库可用 // 文件操作(全 Family;写入原子化,大文件走分块流) @@ -3633,6 +3636,18 @@ await cindy.library({ op: 'db.migrate', dbPath: 'canvas.sqlite', targetVersion: { toVersion: 2, sql: ['CREATE TABLE v2 (a TEXT)'] }] }); await cindy.library({ op: 'db.backup', dbPath: 'library.sqlite' }); // 宿主命名空间 await cindy.library({ op: 'db.check', dbPath: 'library.sqlite' }); // quick_check + +// 后台暂存(staging.*):独立于可迁移 Library 根,不是第二媒体库,不返回 imageRef。 +const up = await cindy.library({ + op: 'staging.begin', taskId, sourceRevision, totalBytes, sha256, mime, recovery, +}); +await cindy.library({ op: 'staging.chunk', stagingId: up.stagingId, seq: 1, content: b64, encoding: 'base64' }); +const receipt = await cindy.library({ op: 'staging.commit', stagingId: up.stagingId }); +// receipt.durable === true 才可当跨退出原件。release 带当前 Library ACK 的 bytes(不是 begin 的 totalBytes),且画布已保存后才调用。 +await cindy.library({ + op: 'staging.release', stagingId: up.stagingId, path, sha256, bytes, + libraryIdentity, libraryGeneration, +}); \`\`\` 关键语义(全部由宿主强制): @@ -3649,6 +3664,7 @@ await cindy.library({ op: 'db.check', dbPath: 'library.sqlite' }); // quick_ch open/status 失败),非法请求=\`INVALID_REQUEST\`(含非法/越界 dbPath 与未知 op), 取消=\`CANCELLED\`;成功 open/status 的 \`state:'unavailable'\` 仍用结果体 reason (如 disk-missing),不是失败 reason 枚举;查询/传输层本地分类 \`TIMEOUT\` / \`TRANSPORT_ERROR\`; +- **staging.***:后台暂存,不是媒体库、不弹新 UI。\`staging.read\` 未传 length 默认 16MiB 分片;负数/NaN offset/length 是 \`PATH_INVALID\`。release 必须带当前 Library ACK 的 \`bytes\`(不是 begin 的 \`totalBytes\`),且只在画布保存后调用。父目录 fsync 失败不得 \`durable:true\`。 - **capabilities**:先查 \`{ op:'capabilities' }\`。仅 \`version===1\` 且 \`operations\` 为**全部字符串**的数组才有效;额外字段忽略,未知 operation 忽略, 已知项保留;有效 v1 清单缺少某项才是 unsupported。缺字段、错类型(含数组内混入 diff --git a/apps/desktop/src/main/cindy-brain/index.ts b/apps/desktop/src/main/cindy-brain/index.ts index bb4b3c78c0..4a6d1ae813 100644 --- a/apps/desktop/src/main/cindy-brain/index.ts +++ b/apps/desktop/src/main/cindy-brain/index.ts @@ -327,6 +327,7 @@ import { GhostFsSlot } from './fsSlot.js'; import { GhostLibrarySlot } from './librarySlot.js'; import { LibraryBindingStore, validateLibraryCandidateLocation } from './libraryBinding.js'; import { LibraryVault, statfsFreeBytes, DEFAULT_LIBRARY_LIMITS } from './libraryVault.js'; +import { LibraryStagingStore } from './libraryStaging.js'; import { LibrarySqlService, defaultLibraryDbWorkerPath } from './librarySqlService.js'; import { trashGhostLibrary } from './libraryTrash.js'; import { migrateGhostLibrary } from './libraryMigrate.js'; @@ -1031,8 +1032,9 @@ export async function interruptGhostCallsForAccountBoundary(): Promise { getForgeOidcInstallConfirmBridge()?.cancelAll(); runtimeSingleton?.destroyAll(); resetNodeRuntimeBrokerForAccountBoundary(); - // Library 会话一并作废:关 db worker + 作废 handle——在途写入已在串行链上 - // 归属原 owner 完成或随 vault.invalidate 作废,新 owner 解析到全新根。 + // Drain in-flight staging.release (tombstone/fsync) before tearing Library + // sessions. Owner mutation leases stay held until each call unwinds; waiting + // for idle first would let marker-window teardown race the lease. await getGhostLibrarySlot().disposeAll(); if (libraryExtraDirSync) { await libraryExtraDirSync(null).catch((error) => { @@ -5403,6 +5405,10 @@ export function getGhostLibrarySlot(): GhostLibrarySlot { getGhost: findAvailableGhost, bindingStore, getDefaultRoot: (ghostId) => ownerScopedUserDataPath('libraries', ghostId), + getStagingRoot: (ghostId) => ownerScopedUserDataPath('library-staging', ghostId), + createStagingStore: (deps) => new LibraryStagingStore(deps), + captureMutationOwner: () => captureGhostMutationOwner(), + beginMutation: (expected) => beginGhostMutation(expected as ActiveAppSession | undefined), captureOwnerScope: () => activeOwnerScopeKey(), createVault: (deps) => new LibraryVault(deps), createSqlService: (deps) => new LibrarySqlService(deps), @@ -5620,30 +5626,36 @@ export async function getGhostLibraryOverview(ghostId: string): Promise { if (!isValidGhostId(ghostId)) return { ok: false, message: '非法插件 id' }; - await getGhostLibrarySlot().disposeGhost(ghostId); - const result = await trashGhostLibrary(ghostId, { - // 默认根与自定义根都经 binding store 的解析口径(漂移时返回 null → 上层 - // 引导恢复位置,不误删)。 - resolveLibraryRoot: async (id) => { - const resolution = await getGhostLibraryBindingStore().resolveLibraryRoot(id); - return resolution.kind === 'custom' ? resolution.root : ownerScopedUserDataPath('libraries', id); - }, - trashRoot: () => ownerScopedUserDataPath('libraries-trash'), - removeBinding: async (id) => { - await getGhostLibraryBindingStore().removeBinding(id); - }, - log, - }); - if (result.ok) { - await refreshMivoLibraryExtraDirGrant().catch((error) => { - log.warn('library extraDirs delete sync failed', { - ghostId, - error: error instanceof Error ? error.message : String(error), - }); + const slot = getGhostLibrarySlot(); + slot.setRelocating(ghostId, true); + try { + await slot.disposeGhost(ghostId); + const result = await trashGhostLibrary(ghostId, { + // 默认根与自定义根都经 binding store 的解析口径(漂移时返回 null → 上层 + // 引导恢复位置,不误删)。 + resolveLibraryRoot: async (id) => { + const resolution = await getGhostLibraryBindingStore().resolveLibraryRoot(id); + return resolution.kind === 'custom' ? resolution.root : ownerScopedUserDataPath('libraries', id); + }, + trashRoot: () => ownerScopedUserDataPath('libraries-trash'), + removeBinding: async (id) => { + await getGhostLibraryBindingStore().removeBinding(id); + }, + log, }); - return { ok: true }; + if (result.ok) { + await refreshMivoLibraryExtraDirGrant().catch((error) => { + log.warn('library extraDirs delete sync failed', { + ghostId, + error: error instanceof Error ? error.message : String(error), + }); + }); + return { ok: true }; + } + return { ok: false, message: result.message }; + } finally { + slot.setRelocating(ghostId, false); } - return { ok: false, message: result.message }; } let libraryBindingStoreSingleton: LibraryBindingStore | null = null; @@ -7977,15 +7989,19 @@ export function registerGhostIpc(): void { throwIpcError('INVALID_PARAMS', '参数非法'); } const releaseMutation = beginGhostMutation(); + const slot = getGhostLibrarySlot(); try { + slot.setRelocating(id, true); + await slot.disposeGhost(id); // drain in-flight staging.release before binding changes const set = await getGhostLibraryBindingStore().setBinding(id, candidate, (root) => statfsFreeBytes(root), ); if (!set.ok) return { ok: false as const, message: set.message }; - await getGhostLibrarySlot().disposeGhost(id); // 作废会话,下一请求用新根 + await slot.disposeGhost(id); // 作废会话,下一请求用新根 await refreshMivoLibraryExtraDirGrant(); return { ok: true as const, warnings: set.warnings }; } finally { + slot.setRelocating(id, false); releaseMutation(); } }); @@ -8021,12 +8037,16 @@ export function registerGhostIpc(): void { assertTrustedAppRendererEvent(event); if (typeof id !== 'string' || !isValidGhostId(id)) throwIpcError('INVALID_PARAMS', '非法插件 id'); const releaseMutation = beginGhostMutation(); + const slot = getGhostLibrarySlot(); try { + slot.setRelocating(id, true); + await slot.disposeGhost(id); await getGhostLibraryBindingStore().removeBinding(id); - await getGhostLibrarySlot().disposeGhost(id); + await slot.disposeGhost(id); await refreshMivoLibraryExtraDirGrant(); return { ok: true as const }; } finally { + slot.setRelocating(id, false); releaseMutation(); } }); diff --git a/apps/desktop/src/main/cindy-brain/libraryBinding.ts b/apps/desktop/src/main/cindy-brain/libraryBinding.ts index f043f3cd10..f1528f531e 100644 --- a/apps/desktop/src/main/cindy-brain/libraryBinding.ts +++ b/apps/desktop/src/main/cindy-brain/libraryBinding.ts @@ -33,6 +33,11 @@ export interface LibraryBindingRecord { grantedAt: number; /** 每次重新绑定递增;迁移切换时原子写入。 */ generation: number; + /** + * false = 授权后尚未成功建出 `/`。缺省/true = 已经建过, + * ghost 子目录 MISSING 时不得空库重建。旧文件无此字段按已建过处理。 + */ + libraryReady?: boolean; } export interface LibraryBindingFileData { @@ -263,6 +268,7 @@ export class LibraryBindingStore { identity, grantedAt: this.now, generation: (prev?.generation ?? 0) + 1, + libraryReady: false, }; data.bindings[ghostId] = record; await this.writeData(data); @@ -286,6 +292,17 @@ export class LibraryBindingStore { return this.readData().then((d) => d.bindings[ghostId] ?? null); } + /** First successful custom open: persist ready without bumping generation. */ + async markLibraryReady(ghostId: string): Promise { + await this.runSerialized(async () => { + const data = await this.readData(); + const rec = data.bindings[ghostId]; + if (!rec || rec.libraryReady === true) return; + data.bindings[ghostId] = { ...rec, libraryReady: true }; + await this.writeData(data); + }); + } + /** * 解析库根:无 binding → 系统默认;有 binding → 漂移检测(realpath 重解 + * identity 比对)。漂移时返回 root:null,上层必须进入 unavailable 状态并 diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 42fe636097..ed903211e3 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -34,6 +34,13 @@ import { LibraryVault, validateLibraryRelPath, DEFAULT_LIBRARY_LIMITS, type Libr import { LibraryBindingStore, type LibraryLocationResolution } from './libraryBinding.js'; import { LibrarySqlService, type LibrarySqlServiceDeps } from './librarySqlService.js'; import type { LibraryDbResult } from './libraryDbCore.js'; +import { + isGhostLibraryStagingOp, + LibraryStagingStore, + type LibraryStagingAck, + type LibraryStagingDeps, + type LibraryStagingFailure, +} from './libraryStaging.js'; /** 正本相对键:assets//<64-hex>/blob.(不是 .)。 */ const LIBRARY_BLOB_REL_RE = /^assets\/([0-9a-f]{2})\/([0-9a-f]{64})\/blob\.([A-Za-z0-9]+)$/i; @@ -207,6 +214,12 @@ export interface GhostLibrarySlotDeps { ghostId: string, root: string | null, ): Promise; + /** owner-scoped staging 根(生产 = ownerScopedUserDataPath('library-staging', ghostId))。 */ + getStagingRoot?(ghostId: string): string; + createStagingStore?(deps: LibraryStagingDeps): LibraryStagingStore; + /** Capture owner before staging awaits; beginMutation holds the lease across verify+release. */ + captureMutationOwner?(): unknown; + beginMutation?(expected?: unknown): () => void; } const fail = ( @@ -246,6 +259,12 @@ export class GhostLibrarySlot { libraryGeneration: number; libraryIdentity: string; }>(); + /** owner-scoped staging,独立于可迁移 Library 根;按 owner×ghost 隔离,根捕获后不漂移。 */ + private readonly stagingStores = new Map(); + /** In-flight staging.release per ghost. disposeGhost drains these so bind/relocate cannot cut the Library mid-transaction. */ + private readonly stagingReleaseInflight = new Map | null; resolveDrain: (() => void) | null }>(); + /** Mutating Library ops and staging.release share one chain per ghost so hash-then-delete cannot race a concurrent write/delete/rename. */ + private readonly ghostExclusive = new Map>(); constructor(private readonly deps: GhostLibrarySlotDeps) {} @@ -255,6 +274,81 @@ export class GhostLibrarySlot { else this.relocating.delete(ghostId); } + private beginStagingRelease(ghostId: string): () => void { + let state = this.stagingReleaseInflight.get(ghostId); + if (!state) { + state = { count: 0, drain: null, resolveDrain: null }; + this.stagingReleaseInflight.set(ghostId, state); + } + if (state.count === 0) { + state.drain = new Promise((resolve) => { + state.resolveDrain = resolve; + }); + } + state.count += 1; + let released = false; + return () => { + if (released) return; + released = true; + const live = this.stagingReleaseInflight.get(ghostId); + if (!live) return; + live.count -= 1; + if (live.count === 0) { + live.resolveDrain?.(); + live.resolveDrain = null; + live.drain = null; + this.stagingReleaseInflight.delete(ghostId); + } + }; + } + + private async waitForStagingReleases(ghostId: string): Promise { + for (;;) { + const state = this.stagingReleaseInflight.get(ghostId); + if (!state || state.count === 0 || !state.drain) return; + await state.drain; + } + } + + private runGhostExclusive(ghostId: string, fn: () => Promise): Promise { + const prev = this.ghostExclusive.get(ghostId) ?? Promise.resolve(); + const next = prev.then(fn, fn); + this.ghostExclusive.set(ghostId, next.then(() => undefined, () => undefined)); + return next; + } + + private async confirmReleaseLibrary( + ghostId: string, + session: GhostLibrarySession, + ack: Extract, + ): Promise { + const live = this.sessions.get(ghostId); + if ( + this.deps.captureOwnerScope() !== session.ownerScopeKey + || !live + || live !== session + || live.identity !== ack.libraryIdentity + || live.generation !== ack.libraryGeneration + ) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 已变化,原件已保留' }; + } + const hashed = await session.vault.hashFile(ack.path); + if (!hashed.ok || hashed.sha256 !== ack.sha256 || hashed.bytes !== ack.bytes) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library 正本已变化,原件已保留' }; + } + return null; + } + + /** Relocate/delete/account-boundary gate: reject new release, drain inflight, then teardown. */ + async invalidateGhost(ghostId: string): Promise { + this.setRelocating(ghostId, true); + try { + await this.disposeGhost(ghostId); + } finally { + this.setRelocating(ghostId, false); + } + } + /** 处理一条 library-request(ghost-pipe:send 的 invoke 返回值即本结果)。 */ async handleLibraryRequest(ghostId: string, payload: unknown): Promise { try { @@ -286,26 +380,38 @@ export class GhostLibrarySlot { capabilities: { version: GHOST_LIBRARY_CAPABILITIES_V1.version, operations: [...GHOST_LIBRARY_CAPABILITIES_V1.operations], + staging: { ...GHOST_LIBRARY_CAPABILITIES_V1.staging }, }, }; } + if (isGhostLibraryStagingOp(op)) { + if (op === 'staging.release') { + if (this.relocating.has(ghostId)) { + return fail('ACK_MISMATCH', 'Library 正在迁移到新位置,原件已保留'); + } + return this.runGhostExclusive(ghostId, () => this.dispatchStaging(ghostId, op, req)); + } + return this.dispatchStaging(ghostId, op, req); + } // 迁移期只读:写类操作在 copying 全程拒绝(读与状态查询照常)。 + const writeOps: ReadonlySet = new Set([ + 'write', 'writeBegin', 'writeChunk', 'writeCommit', 'writeAbort', + 'mkdir', 'delete', 'rename', + 'db.open', 'db.exec', 'db.batch', 'db.migrate', 'db.backup', + ]); if (this.relocating.has(ghostId)) { - const writeOps: ReadonlySet = new Set([ - 'write', 'writeBegin', 'writeChunk', 'writeCommit', 'writeAbort', - 'mkdir', 'delete', 'rename', - 'db.open', 'db.exec', 'db.batch', 'db.migrate', 'db.backup', - ]); if (writeOps.has(op)) { return fail('LIBRARY_READONLY', 'Library 正在迁移到新位置,写入已暂停;请稍后重试'); } } - // 会话获取/作废:owner scope 变了(切换在途或已切),旧会话的根与连接 - // 一并作废——绝不把上个 owner 的库当成本 owner 的库继续用。 - const scopeKey = this.deps.captureOwnerScope(); - const session = await this.getOrCreateSession(ghostId, scopeKey); - return this.runOp(ghostId, session, op, req); + const runSessionOp = async (): Promise => { + const scopeKey = this.deps.captureOwnerScope(); + const session = await this.getOrCreateSession(ghostId, scopeKey); + return this.runOp(ghostId, session, op, req); + }; + if (writeOps.has(op)) return this.runGhostExclusive(ghostId, runSessionOp); + return runSessionOp(); } private async getOrCreateSession(ghostId: string, scopeKey: string | null): Promise { @@ -345,10 +451,15 @@ export class GhostLibrarySlot { && (opened.reason === 'disk-missing' || opened.reason === 'binding-moved') ) { await this.latchCustomUnavailable(session, ghostId, opened.reason); - } else if (session.vault.getMeta()?.orphaned) { - // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned - // 标记(best-effort,失败不影响使用)。 - await session.vault.clearOrphaned().catch(() => {}); + } else { + if (opened.ok && opened.state === 'ready' && resolution.kind === 'custom' && resolution.root !== null) { + await this.deps.bindingStore.markLibraryReady(ghostId).catch(() => {}); + } + if (session.vault.getMeta()?.orphaned) { + // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned + // 标记(best-effort,失败不影响使用)。 + await session.vault.clearOrphaned().catch(() => {}); + } } } else if (this.extraDirGrant?.ghostId === ghostId) { await this.syncAgentReadonlyExtraDir(ghostId, null); @@ -460,7 +571,9 @@ export class GhostLibrarySlot { customParentGrant: resolution.kind === 'custom' && resolution.root !== null ? { realPathAtGrant: resolution.record.realPathAtGrant, identity: resolution.record.identity } : undefined, - log: this.deps.log, + allowCustomInit: resolution.kind === 'custom' && resolution.root !== null + ? resolution.record.libraryReady === false + : undefined, log: this.deps.log, }); const sql = this.deps.createSqlService({ workerScriptPath: this.deps.workerScriptPath, @@ -563,6 +676,219 @@ export class GhostLibrarySlot { } } + private stagingStoreKey(ownerScopeKey: string, ghostId: string): string { + return `${ownerScopeKey}\0${ghostId}`; + } + + private getOrCreateStagingStore(ghostId: string, ownerScopeKey: string): LibraryStagingStore { + const key = this.stagingStoreKey(ownerScopeKey, ghostId); + const existing = this.stagingStores.get(key); + if (existing) return existing; + const rootDir = this.deps.getStagingRoot + ? this.deps.getStagingRoot(ghostId) + : path.join(this.deps.getDefaultRoot(ghostId), '..', '..', 'library-staging', ghostId); + const create = this.deps.createStagingStore ?? ((deps: LibraryStagingDeps) => new LibraryStagingStore(deps)); + const store = create({ + rootDir, + ownerScopeKey, + ghostId, + captureOwnerScope: () => this.deps.captureOwnerScope(), + createVault: (vaultDeps) => this.deps.createVault(vaultDeps), + getDiskFreeBytes: this.deps.getDiskFreeBytes, + log: this.deps.log, + }); + this.stagingStores.set(key, store); + return store; + } + + private stagingFail(r: LibraryStagingFailure): GhostPipeLibraryResult { + return r.errorCode === 'LIBRARY_UNAVAILABLE' + ? fail(r.errorCode, r.message, 'LIBRARY_UNAVAILABLE') + : { ok: false, errorCode: r.errorCode, message: r.message }; + } + + private async verifyLibraryAck( + ghostId: string, + req: Record, + ): Promise { + const pathRel = typeof req.path === 'string' ? req.path : ''; + const sha256 = typeof req.sha256 === 'string' ? req.sha256 : ''; + const bytes = typeof req.bytes === 'number' ? req.bytes : NaN; + const libraryIdentity = typeof req.libraryIdentity === 'string' ? req.libraryIdentity : ''; + const libraryGeneration = typeof req.libraryGeneration === 'number' ? req.libraryGeneration : NaN; + if (!isLibraryBlobRelPath(pathRel) || !/^[0-9a-f]{64}$/.test(sha256) || !Number.isInteger(bytes) || bytes < 0) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library ACK 字段非法,原件已保留' }; + } + const ext = pathRel.split('.').pop() ?? ''; + if (libraryBlobRelPath(sha256, ext) !== pathRel) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library ACK 不是 content-addressed 正本路径,原件已保留' }; + } + if (!/^[0-9a-f]{64}$/.test(libraryIdentity) || !Number.isInteger(libraryGeneration) || libraryGeneration < 0) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 非法,原件已保留' }; + } + const scopeKey = this.deps.captureOwnerScope(); + if (scopeKey === null) { + return { ok: false, errorCode: 'OWNER_CHANGED', message: '当前没有有效账号,staging 已拒绝' }; + } + const session = await this.getOrCreateSession(ghostId, scopeKey); + if (session.drift !== null) { + return { ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: `Library 不可用(${session.drift})` }; + } + if (session.identity !== libraryIdentity || session.generation !== libraryGeneration) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 与当前库不一致,原件已保留' }; + } + const opened = await session.vault.open(); + if (this.deps.captureOwnerScope() !== scopeKey) { + return { ok: false, errorCode: 'OWNER_CHANGED', message: '账号已切换,staging 操作已取消' }; + } + if (!opened.ok) return { ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: opened.message }; + if (opened.state === 'unavailable') { + return { ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: `Library 不可用(${opened.reason ?? 'io'})` }; + } + const hashed = await session.vault.hashFile(pathRel); + if (this.deps.captureOwnerScope() !== scopeKey) { + return { ok: false, errorCode: 'OWNER_CHANGED', message: '账号已切换,staging 操作已取消' }; + } + if (!hashed.ok) { + return hashed.errorCode === 'LIBRARY_UNAVAILABLE' + ? { ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: hashed.message } + : { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library ACK 目标不存在或无法核验,原件已保留' }; + } + if (hashed.sha256 !== sha256 || hashed.bytes !== bytes) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library ACK 与 staging 原件不一致,原件已保留' }; + } + const live = this.sessions.get(ghostId); + if ( + this.relocating.has(ghostId) + || this.deps.captureOwnerScope() !== scopeKey + || !live + || live !== session + || live.identity !== libraryIdentity + || live.generation !== libraryGeneration + ) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 已变化,原件已保留' }; + } + return { ok: true, path: pathRel, sha256, bytes, libraryIdentity, libraryGeneration }; + } + + private async dispatchStaging( + ghostId: string, + op: string, + req: Record, + ): Promise { + const ownerScopeKey = this.deps.captureOwnerScope(); + if (ownerScopeKey === null) { + return fail('OWNER_CHANGED', '当前没有有效账号,staging 已拒绝'); + } + let expectedOwner: unknown; + try { + expectedOwner = this.deps.captureMutationOwner?.(); + } catch (err) { + return fail('OWNER_CHANGED', err instanceof Error ? err.message : '账号切换中,staging 已拒绝'); + } + let releaseLease: (() => void) | undefined; + if (this.deps.beginMutation) { + try { + releaseLease = this.deps.beginMutation(expectedOwner); + } catch (err) { + return fail('OWNER_CHANGED', err instanceof Error ? err.message : '账号已切换,staging 已拒绝'); + } + } + try { + const store = this.getOrCreateStagingStore(ghostId, ownerScopeKey); + switch (op) { + case 'staging.begin': { + const r = await store.begin({ + ghostId, + taskId: req.taskId, + sourceRevision: req.sourceRevision, + totalBytes: req.totalBytes, + sha256: req.sha256, + mime: req.mime, + recovery: req.recovery, + }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.begin', stagingId: r.stagingId }; + } + case 'staging.chunk': { + const r = await store.chunk({ + ghostId, + stagingId: req.stagingId, + seq: req.seq, + content: req.content, + encoding: req.encoding, + }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.chunk', accepted: r.accepted }; + } + case 'staging.commit': { + const r = await store.commit({ ghostId, stagingId: req.stagingId }); + if (!r.ok) return this.stagingFail(r); + return { + ok: true, op: 'staging.commit', + stagingId: r.stagingId, taskId: r.taskId, sourceRevision: r.sourceRevision, + sha256: r.sha256, bytes: r.bytes, mime: r.mime, durable: true, + }; + } + case 'staging.list': { + const r = await store.list({ ghostId, cursor: req.cursor, limit: req.limit }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.list', items: r.items, hasMore: r.hasMore, nextCursor: r.nextCursor }; + } + case 'staging.read': { + const r = await store.read({ + ghostId, + stagingId: req.stagingId, + offset: req.offset, + length: req.length, + }); + if (!r.ok) return this.stagingFail(r); + return { + ok: true, op: 'staging.read', + stagingId: r.stagingId, content: r.content, encoding: r.encoding, + bytes: r.bytes, sha256: r.sha256, + }; + } + case 'staging.abort': { + const r = await store.abort({ ghostId, stagingId: req.stagingId }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.abort', aborted: r.aborted }; + } + case 'staging.release': { + if (this.relocating.has(ghostId)) { + return fail('ACK_MISMATCH', 'Library 正在迁移到新位置,原件已保留'); + } + const releaseDone = this.beginStagingRelease(ghostId); + try { + const ack = await this.verifyLibraryAck(ghostId, req); + if (this.relocating.has(ghostId)) { + return fail('ACK_MISMATCH', 'Library 正在迁移到新位置,原件已保留'); + } + const session = ack.ok === true ? this.sessions.get(ghostId) : undefined; + const r = await store.release({ + ghostId, + stagingId: req.stagingId, + ack, + confirmLibrary: ack.ok !== true + ? undefined + : () => (session + ? this.confirmReleaseLibrary(ghostId, session, ack) + : { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 已变化,原件已保留' }), + }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.release', stagingId: r.stagingId, released: r.released }; + } finally { + releaseDone(); + } + } + default: + return fail('PATH_INVALID', `op 必须是 ${GHOST_LIBRARY_OPS.join(' / ')}`, 'INVALID_REQUEST'); + } + } finally { + releaseLease?.(); + } + } + private async teardownSession(ghostId: string, expected?: GhostLibrarySession): Promise { const session = this.sessions.get(ghostId); if (!session) return; @@ -581,12 +907,34 @@ export class GhostLibrarySlot { /** 停用/卸载/owner 切换收口:作废全部会话(commit 5 的生命周期接线点)。 */ async disposeGhost(ghostId: string): Promise { + await this.waitForStagingReleases(ghostId); await this.teardownSession(ghostId); + await this.disposeStagingStores(ghostId); } async disposeAll(): Promise { - for (const id of Array.from(this.sessions.keys())) { - await this.teardownSession(id); + const ids = new Set([...this.sessions.keys(), ...this.stagingReleaseInflight.keys()]); + for (const key of this.stagingStores.keys()) { + const ghostId = key.split('\0')[1]; + if (ghostId) ids.add(ghostId); + } + for (const id of ids) this.setRelocating(id, true); + try { + for (const id of ids) await this.disposeGhost(id); + await this.disposeStagingStores(); + } finally { + for (const id of ids) this.setRelocating(id, false); + } + } + + private async disposeStagingStores(ghostId?: string): Promise { + const entries = [...this.stagingStores.entries()].filter(([key]) => { + if (!ghostId) return true; + return key.split('\0')[1] === ghostId; + }); + for (const [key, store] of entries) { + this.stagingStores.delete(key); + await store.dispose().catch(() => {}); } } diff --git a/apps/desktop/src/main/cindy-brain/libraryStaging.ts b/apps/desktop/src/main/cindy-brain/libraryStaging.ts new file mode 100644 index 0000000000..774310c173 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/libraryStaging.ts @@ -0,0 +1,1164 @@ +/** + * libraryStaging.ts — LibraryVault adapter for owner-scoped upload staging. + * File bytes/fsync/rename/hash go through LibraryVault. This layer only owns + * task identity, hard quota, recovery manifests, tombstones, and release. + */ + +import { randomUUID } from 'node:crypto'; + +import { + LibraryVault, + DEFAULT_LIBRARY_LIMITS, + type LibraryVaultDeps, +} from './libraryVault.js'; + +export type LibraryStagingErrorCode = + | 'PATH_INVALID' + | 'TOO_LARGE' + | 'STAGING_QUOTA' + | 'STAGING_BUSY' + | 'DISK_FULL' + | 'STREAM_INVALID' + | 'NOT_FOUND' + | 'ALREADY_EXISTS' + | 'OWNER_CHANGED' + | 'ACK_MISMATCH' + | 'LIBRARY_UNAVAILABLE' + | 'INTERNAL'; + +export type LibraryStagingFailure = { ok: false; errorCode: LibraryStagingErrorCode; message: string }; +export type LibraryStagingSuccess = { ok: true } & T; +export type LibraryStagingResult = LibraryStagingSuccess | LibraryStagingFailure; + +export interface LibraryStagingLimits { + maxTaskBytes: number; + maxTotalBytes: number; + maxConcurrentWrites: number; + maxChunkBytes: number; + reserveBytes: number; + /** Incomplete upload idle timeout; durables/commitPending are never swept. */ + streamIdleTimeoutMs: number; + maxRecoveryMetadataBytes: number; + defaultListLimit: number; + maxListLimit: number; +} + +export const DEFAULT_LIBRARY_STAGING_LIMITS: LibraryStagingLimits = { + maxTaskBytes: 8 * 1024 * 1024 * 1024, + maxTotalBytes: 8 * 1024 * 1024 * 1024, + maxConcurrentWrites: 4, + maxChunkBytes: 16 * 1024 * 1024, + reserveBytes: 1024 * 1024 * 1024, + streamIdleTimeoutMs: DEFAULT_LIBRARY_LIMITS.streamIdleTimeoutMs, + maxRecoveryMetadataBytes: 64 * 1024, + defaultListLimit: 100, + maxListLimit: 500, +}; + +export const GHOST_LIBRARY_STAGING_OPS = [ + 'staging.begin', + 'staging.chunk', + 'staging.commit', + 'staging.list', + 'staging.read', + 'staging.release', + 'staging.abort', +] as const; +export type GhostLibraryStagingOp = (typeof GHOST_LIBRARY_STAGING_OPS)[number]; + +export interface LibraryStagingReceipt { + stagingId: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + durable: true; +} + +export interface LibraryStagingListItem extends LibraryStagingReceipt { + recovery: Record; +} + +export type LibraryStagingAck = + | { + ok: true; + path: string; + sha256: string; + bytes: number; + libraryIdentity: string; + libraryGeneration: number; + } + | LibraryStagingFailure; + +export interface LibraryStagingDeps { + rootDir: string; + ownerScopeKey: string; + ghostId: string; + captureOwnerScope(): string | null; + createVault?(deps: LibraryVaultDeps): LibraryVault; + getDiskFreeBytes?(root: string): Promise; + log?: LibraryVaultDeps['log']; + limits?: Partial; + now?(): number; +} + +const HEX64 = /^[0-9a-f]{64}$/; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const TASK_ID_MAX = 128; +const MIME_MAX = 256; + +const fail = (errorCode: LibraryStagingErrorCode, message: string): LibraryStagingFailure => ({ + ok: false, errorCode, message, +}); + +export function isGhostLibraryStagingOp(op: string): op is GhostLibraryStagingOp { + return (GHOST_LIBRARY_STAGING_OPS as readonly string[]).includes(op); +} + +function vaultFail(r: { errorCode: string; message: string }): LibraryStagingFailure { + const code = r.errorCode as LibraryStagingErrorCode; + const allowed: LibraryStagingErrorCode[] = [ + 'PATH_INVALID', 'TOO_LARGE', 'DISK_FULL', 'STREAM_INVALID', 'NOT_FOUND', + 'ALREADY_EXISTS', 'LIBRARY_UNAVAILABLE', 'INTERNAL', + ]; + return fail(allowed.includes(code) ? code : 'INTERNAL', r.message); +} + +function parseBoundedString(value: unknown, field: string, max: number): string | LibraryStagingFailure { + if (typeof value !== 'string' || value.length === 0 || value.length > max) { + return fail('PATH_INVALID', `${field} 必须是 1..${max} 字符`); + } + return value; +} + +function parseSha256(value: unknown): string | LibraryStagingFailure { + if (typeof value !== 'string' || !HEX64.test(value)) return fail('PATH_INVALID', 'sha256 必须是 64 位小写十六进制'); + return value; +} + +function parseStagingId(value: unknown): string | LibraryStagingFailure { + if (typeof value !== 'string' || !UUID.test(value)) return fail('PATH_INVALID', 'stagingId 必须是 UUID'); + return value; +} + +function parseRecovery(value: unknown, maxBytes: number): LibraryStagingResult<{ recovery: Record }> { + if (value === undefined || value === null || typeof value !== 'object' || Array.isArray(value)) { + return fail('PATH_INVALID', 'recovery 必须是 JSON 对象'); + } + let serialized: string; + try { + serialized = JSON.stringify(value); + } catch { + return fail('PATH_INVALID', 'recovery 不是合法 JSON'); + } + if (Buffer.byteLength(serialized, 'utf8') > maxBytes) { + return fail('TOO_LARGE', `recovery 超上限(${maxBytes} 字节)`); + } + return { ok: true, recovery: JSON.parse(serialized) as Record }; +} + +function decodeStrictBase64(content: unknown, maxBytes: number): Buffer | LibraryStagingFailure { + if (typeof content !== 'string') return fail('PATH_INVALID', 'content 必须是 base64 字符串'); + const compact = content.replace(/[\r\n]/g, ''); + if (compact.length === 0) return Buffer.alloc(0); + const maxChars = Math.floor((maxBytes * 4) / 3) + 8; + if (compact.length > maxChars) return fail('TOO_LARGE', `单块超上限(${maxBytes} 字节)`); + if (compact.length % 4 !== 0) return fail('PATH_INVALID', 'content 不是合法 base64'); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) return fail('PATH_INVALID', 'content 不是合法 base64'); + const decoded = Buffer.from(compact, 'base64'); + if (decoded.toString('base64') !== compact) return fail('PATH_INVALID', 'content 不是合法 base64'); + if (decoded.byteLength > maxBytes) return fail('TOO_LARGE', `单块超上限(${maxBytes} 字节)`); + return decoded; +} + +function taskKey(taskId: string, sourceRevision: string): string { + return `${taskId}\0${sourceRevision}`; +} + +function sameRecovery(a: Record, b: Record): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function blobPath(id: string): string { + return `tasks/${id}/blob.bin`; +} +function intentPath(id: string): string { + return `tasks/${id}/intent.json`; +} +function manifestPath(id: string): string { + return `tasks/${id}/manifest.json`; +} +function tombstonePath(id: string): string { + return `tasks/${id}/tombstone.json`; +} + +interface UploadRecord { + stagingId: string; + streamId: string; + taskId: string; + sourceRevision: string; + totalBytes: number; + sha256Declared: string; + mime: string; + recovery: Record; + nextSeq: number; + lastChunk: Buffer | null; + lastAt: number; + written: number; + /** writeCommit succeeded; keep mapping until manifest+dirsync durable. */ + commitPending: boolean; +} + +interface DurableRecord { + stagingId: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + recovery: Record; +} + +interface TaskIdentity { + stagingId: string; + ghostId: string; + ownerScopeKey: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + recovery: Record; +} + +interface DurableManifest extends TaskIdentity { + version: 1; + durable: true; +} + +interface IntentMarker extends TaskIdentity { + version: 1; + intent: true; +} + +function receiptOf(record: DurableRecord): LibraryStagingReceipt { + return { + stagingId: record.stagingId, + taskId: record.taskId, + sourceRevision: record.sourceRevision, + sha256: record.sha256, + bytes: record.bytes, + mime: record.mime, + durable: true, + }; +} + +/** Disk identity ignores process-lifetime generation (`local:id:2` ≡ `local:id:0`). */ +function durableOwnerScopeKey(scopeKey: string): string { + const parsed = /^(local|cloud|signed-out):(.+):(\d+)$/.exec(scopeKey); + return parsed ? `${parsed[1]}:${parsed[2]}` : scopeKey; +} + +function sameDurableOwner(persisted: unknown, live: string): boolean { + return typeof persisted === 'string' && durableOwnerScopeKey(persisted) === durableOwnerScopeKey(live); +} + +function parseTaskIdentity( + parsed: Record, + stagingId: string, + ghostId: string, + ownerScopeKey: string, + kind: 'manifest' | 'intent', +): TaskIdentity | LibraryStagingFailure { + const label = kind === 'manifest' ? 'staging manifest' : 'staging intent'; + if ( + parsed.stagingId !== stagingId + || parsed.ghostId !== ghostId + || !sameDurableOwner(parsed.ownerScopeKey, ownerScopeKey) + || typeof parsed.taskId !== 'string' || parsed.taskId.length === 0 || parsed.taskId.length > TASK_ID_MAX + || typeof parsed.sourceRevision !== 'string' || parsed.sourceRevision.length === 0 || parsed.sourceRevision.length > TASK_ID_MAX + || typeof parsed.sha256 !== 'string' || !HEX64.test(parsed.sha256) + || typeof parsed.bytes !== 'number' || !Number.isInteger(parsed.bytes) || parsed.bytes < 0 + || typeof parsed.mime !== 'string' || parsed.mime.length === 0 || parsed.mime.length > MIME_MAX + || typeof parsed.recovery !== 'object' || parsed.recovery === null || Array.isArray(parsed.recovery) + ) { + return fail('LIBRARY_UNAVAILABLE', `${label} 字段非法`); + } + return { + stagingId, + ghostId, + ownerScopeKey, + taskId: parsed.taskId, + sourceRevision: parsed.sourceRevision, + sha256: parsed.sha256, + bytes: parsed.bytes, + mime: parsed.mime, + recovery: parsed.recovery as Record, + }; +} + +function parseJsonObject(raw: string, label: string): { ok: true; value: Record } | LibraryStagingFailure { + if (Buffer.byteLength(raw, 'utf8') > 256 * 1024) return fail('LIBRARY_UNAVAILABLE', `${label} 过大`); + try { + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return fail('LIBRARY_UNAVAILABLE', `${label} 不可读`); + } + return { ok: true, value: parsed as Record }; + } catch { + return fail('LIBRARY_UNAVAILABLE', `${label} 不可读`); + } +} + +function parseManifest( + raw: string, + stagingId: string, + ghostId: string, + ownerScopeKey: string, +): DurableManifest | LibraryStagingFailure { + const parsed = parseJsonObject(raw, 'staging manifest'); + if (!parsed.ok) return parsed; + if (parsed.value.version !== 1 || parsed.value.durable !== true || parsed.value.intent === true) { + return fail('LIBRARY_UNAVAILABLE', 'staging manifest 字段非法'); + } + const identity = parseTaskIdentity(parsed.value, stagingId, ghostId, ownerScopeKey, 'manifest'); + if ('errorCode' in identity) return identity; + return { ...identity, version: 1, durable: true }; +} + +function parseIntent( + raw: string, + stagingId: string, + ghostId: string, + ownerScopeKey: string, +): IntentMarker | LibraryStagingFailure { + const parsed = parseJsonObject(raw, 'staging intent'); + if (!parsed.ok) return parsed; + if (parsed.value.version !== 1 || parsed.value.intent !== true || parsed.value.durable === true) { + return fail('LIBRARY_UNAVAILABLE', 'staging intent 字段非法'); + } + const identity = parseTaskIdentity(parsed.value, stagingId, ghostId, ownerScopeKey, 'intent'); + if ('errorCode' in identity) return identity; + return { ...identity, version: 1, intent: true }; +} + +function identityMatches(actual: TaskIdentity, expected: TaskIdentity): boolean { + return actual.stagingId === expected.stagingId + && actual.ghostId === expected.ghostId + && actual.ownerScopeKey === expected.ownerScopeKey + && actual.taskId === expected.taskId + && actual.sourceRevision === expected.sourceRevision + && actual.sha256 === expected.sha256 + && actual.bytes === expected.bytes + && actual.mime === expected.mime + && sameRecovery(actual.recovery, expected.recovery); +} + +export class LibraryStagingStore { + private readonly limits: LibraryStagingLimits; + private readonly ownerScopeKey: string; + private readonly ghostId: string; + private readonly now: () => number; + private readonly vault: LibraryVault; + private readonly uploads = new Map(); + private readonly byTask = new Map(); + private durables = new Map(); + private durableBytes = 0; + private orphanBlobBytes = 0; + private closedTmpBytes = 0; + private chain: Promise = Promise.resolve(); + private opened = false; + private journalReady = false; + private closedTmpStale = false; + + constructor(private readonly deps: LibraryStagingDeps) { + this.limits = { ...DEFAULT_LIBRARY_STAGING_LIMITS, ...(deps.limits ?? {}) }; + this.ownerScopeKey = deps.ownerScopeKey; + this.ghostId = deps.ghostId; + this.now = deps.now ?? Date.now; + const capturedRoot = deps.rootDir; + const createVault = deps.createVault ?? ((vaultDeps: LibraryVaultDeps) => new LibraryVault(vaultDeps)); + this.vault = createVault({ + rootDir: () => capturedRoot, + ghostId: deps.ghostId, + getDiskFreeBytes: deps.getDiskFreeBytes, + log: deps.log, + limits: { + writeMaxBytes: this.limits.maxChunkBytes, + readMaxBytes: this.limits.maxChunkBytes, + streamMaxTotalBytes: this.limits.maxTaskBytes, + diskReserveBytes: this.limits.reserveBytes, + softLimitBytes: this.limits.maxTotalBytes, + }, + onStreamClosed: (streamId) => { + for (const [id, upload] of this.uploads) { + if (upload.streamId !== streamId || upload.commitPending) continue; + this.uploads.delete(id); + this.byTask.delete(taskKey(upload.taskId, upload.sourceRevision)); + this.closedTmpStale = true; + } + }, + }); + } + + private runSerialized(fn: () => Promise): Promise { + const next = this.chain.then(fn, fn); + this.chain = next.catch(() => {}); + return next; + } + + private requireOwner(): LibraryStagingFailure | null { + const live = this.deps.captureOwnerScope(); + if (live === null || live !== this.ownerScopeKey) { + return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + } + return null; + } + + private async ensureOpen(): Promise { + const opened = await this.vault.open(); + if (!opened.ok) return vaultFail(opened); + if (opened.state === 'unavailable') { + return fail('LIBRARY_UNAVAILABLE', `staging 不可用(${opened.reason ?? 'io'})`); + } + this.opened = true; + return null; + } + + private async listAll(rel: string): Promise }>> { + const entries: Array<{ path: string; kind: 'file' | 'dir'; bytes: number }> = []; + let cursor: string | null = null; + for (;;) { + const page = await this.vault.list({ + path: rel, + recursive: false, + cursor, + limit: DEFAULT_LIBRARY_LIMITS.listPageSize, + strict: true, + }); + if (!page.ok) { + if (page.errorCode === 'NOT_FOUND' && entries.length === 0) { + return { ok: true, entries: [] }; + } + return vaultFail(page); + } + entries.push(...page.entries.map((item) => ({ path: item.path, kind: item.kind, bytes: item.bytes }))); + if (!page.hasMore || page.nextCursor === null) break; + cursor = page.nextCursor; + } + return { ok: true, entries }; + } + + private async loadJournal(): Promise { + if (this.journalReady) return null; + const openFail = await this.ensureOpen(); + if (openFail) return openFail; + const residue = await this.vault.tmpResidueBytes(); + if (!residue.ok) return vaultFail(residue); + const listed = await this.listAll('tasks'); + if (!listed.ok) return listed; + const next = new Map(); + let durableBytes = 0; + let orphanBlobBytes = 0; + for (const entry of listed.entries) { + if (entry.kind !== 'dir' || !UUID.test(entry.path.slice('tasks/'.length))) continue; + const stagingId = entry.path.slice('tasks/'.length); + const inner = await this.listAll(entry.path); + if (!inner.ok) return inner; + const names = new Set(inner.entries.map((item) => item.path.split('/').pop())); + if (names.has('tombstone.json')) { + const marker = await this.readTombstone(stagingId); + if (!marker.ok) return marker; + const cleaned = await this.finishReleaseUnlocked(stagingId); + if (cleaned) { + // Fail closed without installing a partial journal. Retry after repair + // must not see a half-loaded durables map. Do not count the pre-delete + // leftover size as orphan quota: cleanup already removed those bytes. + return cleaned; + } + continue; + } + if (names.has('manifest.json')) { + const recovered = await this.recoverDurableFromDisk(stagingId, names.has('intent.json')); + if (!recovered.ok) return recovered; + next.set(stagingId, recovered.record); + durableBytes += recovered.record.bytes; + continue; + } + if (names.has('intent.json') && names.has('blob.bin')) { + const recovered = await this.recoverDurableFromIntent(stagingId); + if (!recovered.ok) return recovered; + next.set(stagingId, recovered.record); + durableBytes += recovered.record.bytes; + continue; + } + const blob = inner.entries.find((item) => item.path.endsWith('/blob.bin') && item.kind === 'file'); + if (blob) orphanBlobBytes += blob.bytes; + } + this.durables = next; + this.durableBytes = durableBytes; + this.orphanBlobBytes = orphanBlobBytes; + this.closedTmpBytes = residue.bytes; + for (const record of next.values()) { + this.byTask.set(taskKey(record.taskId, record.sourceRevision), record.stagingId); + } + for (const upload of this.uploads.values()) { + this.byTask.set(taskKey(upload.taskId, upload.sourceRevision), upload.stagingId); + } + this.journalReady = true; + return null; + } + + /** In-memory uploads already reserve their declared size; do not also add vault stream bytes. */ + private trackedUploadBytes(): number { + let total = 0; + for (const upload of this.uploads.values()) total += upload.totalBytes; + return total; + } + + private quotaBytes(): number { + return this.durableBytes + + this.orphanBlobBytes + + this.closedTmpBytes + + this.trackedUploadBytes(); + } + + /** Idle incomplete uploads only. Durables and commitPending originals are never TTL-deleted. */ + private async sweepIdleUploadsUnlocked(): Promise { + const cutoff = this.now() - this.limits.streamIdleTimeoutMs; + for (const upload of [...this.uploads.values()]) { + if (upload.commitPending || upload.lastAt >= cutoff) continue; + await this.vault.writeAbort({ streamId: upload.streamId }).catch(() => {}); + this.uploads.delete(upload.stagingId); + this.byTask.delete(taskKey(upload.taskId, upload.sourceRevision)); + await this.vault.delete({ path: intentPath(upload.stagingId) }).catch(() => {}); + this.closedTmpStale = true; + } + } + + /** Bytes declared but not yet on disk. commitPending blobs are already landed. */ + private unwrittenReservationBytes(): number { + let total = 0; + for (const upload of this.uploads.values()) { + if (upload.commitPending) continue; + total += Math.max(0, upload.totalBytes - upload.written); + } + return total; + } + + private async checkDiskReserveUnlocked(extraBytes: number): Promise { + if (!this.deps.getDiskFreeBytes) return null; + let free: number | null = null; + try { + free = await this.deps.getDiskFreeBytes(this.deps.rootDir); + } catch { + return null; + } + if (free !== null && free - this.unwrittenReservationBytes() - extraBytes < this.limits.reserveBytes) { + return fail('DISK_FULL', `磁盘剩余空间低于保留水位(${this.limits.reserveBytes} 字节);请清理磁盘或确认归档后释放`); + } + return null; + } + + private async requireReady(): Promise { + const denied = this.requireOwner(); + if (denied) return denied; + const loaded = await this.loadJournal(); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (loaded) return loaded; + if (this.closedTmpStale) { + const residue = await this.refreshClosedTmp(); + if (residue) return residue; + this.closedTmpStale = false; + } + return null; + } + + private async refreshClosedTmp(): Promise { + const residue = await this.vault.tmpResidueBytes(); + if (!residue.ok) return vaultFail(residue); + this.closedTmpBytes = residue.bytes; + return null; + } + + /** New tasks/ is not durable until the parent tasks dir, vault root, and any newly created root ancestors are fsynced. */ + private async fsyncDurablePath(stagingId: string): Promise { + for (const rel of [`tasks/${stagingId}`, 'tasks', ''] as const) { + const synced = await this.vault.fsyncDir(rel); + if (!synced.ok) return vaultFail(synced); + if (process.platform !== 'win32' && synced.fsynced !== true) { + return fail('INTERNAL', '目录 fsync 失败'); + } + } + const ancestors = await this.vault.fsyncCreatedAncestors(); + if (!ancestors.ok) return vaultFail(ancestors); + if (process.platform !== 'win32' && ancestors.fsynced !== true) { + return fail('INTERNAL', '根目录项 fsync 失败'); + } + return null; + } + + private async readTombstone(stagingId: string): Promise> { + const raw = await this.vault.read({ path: tombstonePath(stagingId), encoding: 'utf8' }); + if (!raw.ok) { + return raw.errorCode === 'NOT_FOUND' + ? fail('NOT_FOUND', 'stagingId 无效') + : fail('LIBRARY_UNAVAILABLE', 'staging tombstone 不可读'); + } + let parsed: { version?: unknown; stagingId?: unknown; released?: unknown }; + try { + parsed = JSON.parse(raw.content) as { version?: unknown; stagingId?: unknown; released?: unknown }; + } catch { + return fail('LIBRARY_UNAVAILABLE', 'staging tombstone 不可读'); + } + if (parsed.version !== 1 || parsed.stagingId !== stagingId || parsed.released !== true) { + return fail('LIBRARY_UNAVAILABLE', 'staging tombstone 字段非法'); + } + return { ok: true, stagingId }; + } + + private findByTask(taskId: string, sourceRevision: string): DurableRecord | UploadRecord | undefined { + const id = this.byTask.get(taskKey(taskId, sourceRevision)); + if (!id) return undefined; + return this.durables.get(id) ?? this.uploads.get(id); + } + + private durableFromIdentity(identity: TaskIdentity): DurableRecord { + return { + stagingId: identity.stagingId, + taskId: identity.taskId, + sourceRevision: identity.sourceRevision, + sha256: identity.sha256, + bytes: identity.bytes, + mime: identity.mime, + recovery: identity.recovery, + }; + } + + private expectedIdentity(upload: UploadRecord, hashed: { sha256: string; bytes: number }): TaskIdentity { + return { + stagingId: upload.stagingId, + ghostId: this.ghostId, + ownerScopeKey: this.ownerScopeKey, + taskId: upload.taskId, + sourceRevision: upload.sourceRevision, + sha256: hashed.sha256, + bytes: hashed.bytes, + mime: upload.mime, + recovery: upload.recovery, + }; + } + + private async hashMatchesIdentity(identity: TaskIdentity): Promise> { + const hashed = await this.vault.hashFile(blobPath(identity.stagingId)); + if (!hashed.ok) return fail('LIBRARY_UNAVAILABLE', 'staging 原件缺失或不可读'); + if (hashed.sha256 !== identity.sha256 || hashed.bytes !== identity.bytes) { + return fail('LIBRARY_UNAVAILABLE', 'staging 原件与声明身份不一致'); + } + return { ok: true, record: this.durableFromIdentity(identity) }; + } + + private async recoverDurableFromDisk( + stagingId: string, + hasIntent: boolean, + ): Promise> { + const raw = await this.vault.read({ path: manifestPath(stagingId), encoding: 'utf8' }); + if (!raw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging manifest 不可读'); + const parsed = parseManifest(raw.content, stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in parsed) return parsed; + if (hasIntent) { + const intentRaw = await this.vault.read({ path: intentPath(stagingId), encoding: 'utf8' }); + if (!intentRaw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging intent 不可读'); + const intent = parseIntent(intentRaw.content, stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in intent) return intent; + if (!identityMatches(parsed, intent)) { + return fail('LIBRARY_UNAVAILABLE', 'staging intent 与 manifest 冲突'); + } + } + return this.hashMatchesIdentity(parsed); + } + + private async recoverDurableFromIntent(stagingId: string): Promise> { + const raw = await this.vault.read({ path: intentPath(stagingId), encoding: 'utf8' }); + if (!raw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging intent 不可读'); + const intent = parseIntent(raw.content, stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in intent) return intent; + const matched = await this.hashMatchesIdentity(intent); + if (!matched.ok) return matched; + const promoted = await this.writeManifestUnlocked(intent); + if (promoted) return promoted; + return { ok: true, record: matched.record }; + } + + private async writeIntentUnlocked(identity: TaskIdentity): Promise { + const marker: IntentMarker = { ...identity, version: 1, intent: true }; + const written = await this.vault.write({ + path: intentPath(identity.stagingId), + content: JSON.stringify(marker), + ifNotExists: true, + }); + if (!written.ok && written.errorCode !== 'ALREADY_EXISTS') return vaultFail(written); + if (!written.ok) { + const raw = await this.vault.read({ path: intentPath(identity.stagingId), encoding: 'utf8' }); + if (!raw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging intent 不可读'); + const existing = parseIntent(raw.content, identity.stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in existing) return existing; + if (!identityMatches(existing, identity)) { + return fail('ALREADY_EXISTS', 'staging intent 与当前任务身份冲突'); + } + } + const synced = await this.fsyncDurablePath(identity.stagingId); + if (synced) { + const deleted = await this.vault.delete({ path: intentPath(identity.stagingId) }); + if (!deleted.ok && deleted.errorCode !== 'NOT_FOUND') return vaultFail(deleted); + return synced; + } + return null; + } + + private async writeManifestUnlocked(identity: TaskIdentity): Promise { + const manifest: DurableManifest = { ...identity, version: 1, durable: true }; + const written = await this.vault.write({ + path: manifestPath(identity.stagingId), + content: JSON.stringify(manifest), + ifNotExists: true, + }); + if (!written.ok && written.errorCode !== 'ALREADY_EXISTS') return vaultFail(written); + let created = written.ok; + if (!written.ok) { + const adopted = await this.adoptExistingManifest(identity); + if (adopted.error) return adopted.error; + created = false; + } + const journalSync = await this.fsyncDurablePath(identity.stagingId); + if (journalSync) { + if (created) { + const deleted = await this.vault.delete({ path: manifestPath(identity.stagingId) }); + if (!deleted.ok && deleted.errorCode !== 'NOT_FOUND') return vaultFail(deleted); + } + return journalSync; + } + return null; + } + + private async adoptExistingManifest( + expected: TaskIdentity, + ): Promise<{ error: LibraryStagingFailure | null }> { + const raw = await this.vault.read({ path: manifestPath(expected.stagingId), encoding: 'utf8' }); + if (!raw.ok) return { error: fail('LIBRARY_UNAVAILABLE', 'staging manifest 不可读') }; + const parsed = parseManifest(raw.content, expected.stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in parsed) return { error: parsed }; + if (!identityMatches(parsed, expected)) { + return { error: fail('ALREADY_EXISTS', '已有 manifest 与当前任务身份冲突') }; + } + return { error: null }; + } + + private async finishReleaseUnlocked(stagingId: string): Promise { + const blob = await this.vault.delete({ path: blobPath(stagingId) }); + if (!blob.ok && blob.errorCode !== 'NOT_FOUND') return vaultFail(blob); + const manifest = await this.vault.delete({ path: manifestPath(stagingId) }); + if (!manifest.ok && manifest.errorCode !== 'NOT_FOUND') return vaultFail(manifest); + const intent = await this.vault.delete({ path: intentPath(stagingId) }); + if (!intent.ok && intent.errorCode !== 'NOT_FOUND') return vaultFail(intent); + await this.vault.delete({ path: tombstonePath(stagingId) }).catch(() => {}); + return null; + } + + async begin(req: { + ghostId: string; + taskId: unknown; + sourceRevision: unknown; + totalBytes: unknown; + sha256: unknown; + mime: unknown; + recovery: unknown; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const taskId = parseBoundedString(req.taskId, 'taskId', TASK_ID_MAX); + if (typeof taskId !== 'string') return taskId; + const sourceRevision = parseBoundedString(req.sourceRevision, 'sourceRevision', TASK_ID_MAX); + if (typeof sourceRevision !== 'string') return sourceRevision; + const mime = parseBoundedString(req.mime, 'mime', MIME_MAX); + if (typeof mime !== 'string') return mime; + const sha256 = parseSha256(req.sha256); + if (typeof sha256 !== 'string') return sha256; + if (typeof req.totalBytes !== 'number' || !Number.isInteger(req.totalBytes) || req.totalBytes < 0) { + return fail('PATH_INVALID', 'totalBytes 必须是非负整数'); + } + if (req.totalBytes > this.limits.maxTaskBytes) { + return fail('TOO_LARGE', `分块流总大小超上限(${this.limits.maxTaskBytes} 字节)`); + } + const parsedRecovery = parseRecovery(req.recovery, this.limits.maxRecoveryMetadataBytes); + if (!parsedRecovery.ok) return parsedRecovery; + const existing = this.findByTask(taskId, sourceRevision); + if (existing) { + const same = 'durable' in existing === false + && 'streamId' in existing + && existing.mime === mime + && existing.totalBytes === req.totalBytes + && existing.sha256Declared === sha256 + && sameRecovery(existing.recovery, parsedRecovery.recovery); + const sameDurable = this.durables.get((existing as DurableRecord).stagingId) + && (existing as DurableRecord).mime === mime + && (existing as DurableRecord).bytes === req.totalBytes + && (existing as DurableRecord).sha256 === sha256 + && sameRecovery((existing as DurableRecord).recovery, parsedRecovery.recovery); + if (same || sameDurable) { + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + return { ok: true as const, stagingId: existing.stagingId }; + } + return fail('ALREADY_EXISTS', '同一 task/revision 已有不同元数据的原件'); + } + await this.sweepIdleUploadsUnlocked(); + if (this.closedTmpStale) { + const residue = await this.refreshClosedTmp(); + if (residue) return residue; + this.closedTmpStale = false; + } + if (this.uploads.size >= this.limits.maxConcurrentWrites) { + return fail('STAGING_BUSY', '并发上传已达上限,请稍后重试'); + } + if (this.quotaBytes() + req.totalBytes > this.limits.maxTotalBytes) { + return fail('STAGING_QUOTA', 'staging 总容量不足,请在确认归档后释放再试'); + } + const disk = await this.checkDiskReserveUnlocked(req.totalBytes); + if (disk) return disk; + const stagingId = randomUUID(); + const identity: TaskIdentity = { + stagingId, + ghostId: this.ghostId, + ownerScopeKey: this.ownerScopeKey, + taskId, + sourceRevision, + sha256, + bytes: req.totalBytes, + mime, + recovery: parsedRecovery.recovery, + }; + const intentFail = await this.writeIntentUnlocked(identity); + if (intentFail) return intentFail; + if (this.requireOwner()) { + await this.vault.delete({ path: intentPath(stagingId) }).catch(() => {}); + return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + } + const begin = await this.vault.writeBegin({ + path: blobPath(stagingId), + totalBytes: req.totalBytes, + sha256, + }); + if (this.requireOwner()) { + if (begin.ok) await this.vault.writeAbort({ streamId: begin.streamId }).catch(() => {}); + return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + } + if (!begin.ok) { + await this.vault.delete({ path: intentPath(stagingId) }).catch(() => {}); + return vaultFail(begin); + } + this.uploads.set(stagingId, { + stagingId, + streamId: begin.streamId, + taskId, + sourceRevision, + totalBytes: req.totalBytes, + sha256Declared: sha256, + mime, + recovery: parsedRecovery.recovery, + nextSeq: 1, + lastChunk: null, + lastAt: this.now(), + written: 0, + commitPending: false, + }); + this.byTask.set(taskKey(taskId, sourceRevision), stagingId); + return { ok: true as const, stagingId }; + }); + } + + async chunk(req: { + ghostId: string; + stagingId: unknown; + seq: unknown; + content: unknown; + encoding?: unknown; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + if (req.encoding !== undefined && req.encoding !== 'base64') { + return fail('PATH_INVALID', 'encoding 只支持 "base64"'); + } + const upload = this.uploads.get(stagingId); + if (!upload) { + if (this.durables.has(stagingId)) return fail('STREAM_INVALID', '已提交的原件不能再写分块'); + return fail('NOT_FOUND', 'stagingId 无效'); + } + if (typeof req.seq !== 'number' || !Number.isInteger(req.seq) || req.seq < 1) { + return fail('STREAM_INVALID', 'seq 必须从 1 起连续'); + } + const decoded = decodeStrictBase64(req.content, this.limits.maxChunkBytes); + if (!Buffer.isBuffer(decoded)) return decoded; + if (req.seq === upload.nextSeq - 1 && upload.lastChunk && upload.lastChunk.equals(decoded)) { + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + return { ok: true as const, accepted: decoded.byteLength }; + } + const chunk = await this.vault.writeChunk({ + streamId: upload.streamId, + seq: req.seq, + content: decoded.toString('base64'), + encoding: 'base64', + }); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (!chunk.ok) return vaultFail(chunk); + upload.nextSeq = req.seq + 1; + upload.lastChunk = decoded; + upload.lastAt = this.now(); + upload.written += decoded.byteLength; + return { ok: true as const, accepted: chunk.accepted }; + }); + } + + async commit(req: { ghostId: string; stagingId: unknown }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + const durable = this.durables.get(stagingId); + if (durable) { + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + return { ok: true as const, ...receiptOf(durable) }; + } + const upload = this.uploads.get(stagingId); + if (!upload) return fail('NOT_FOUND', 'stagingId 无效'); + if (!upload.commitPending) { + upload.commitPending = true; + const committed = await this.vault.writeCommit({ streamId: upload.streamId }); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (!committed.ok) { + this.uploads.delete(stagingId); + this.byTask.delete(taskKey(upload.taskId, upload.sourceRevision)); + const residue = await this.refreshClosedTmp(); + const leftover = await this.vault.stat({ path: blobPath(stagingId) }); + if (leftover.ok && leftover.kind === 'file') this.orphanBlobBytes += leftover.bytes; + if (residue) return residue; + return vaultFail(committed); + } + } + const hashed = await this.vault.hashFile(blobPath(stagingId)); + if (!hashed.ok || hashed.sha256 !== upload.sha256Declared || hashed.bytes !== upload.totalBytes) { + return fail('STREAM_INVALID', 'sha256 校验失败(声明值与实际字节不一致)'); + } + const blobSync = await this.fsyncDurablePath(stagingId); + if (blobSync) return blobSync; + const identity = this.expectedIdentity(upload, hashed); + const written = await this.writeManifestUnlocked(identity); + if (written) return written; + const record = this.durableFromIdentity(identity); + this.uploads.delete(stagingId); + this.durables.set(stagingId, record); + this.durableBytes += hashed.bytes; + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + return { ok: true as const, ...receiptOf(record) }; + }); + } + + async list(req: { + ghostId: string; + cursor?: unknown; + limit?: unknown; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const limit = req.limit === undefined + ? this.limits.defaultListLimit + : (typeof req.limit === 'number' && Number.isInteger(req.limit) && req.limit >= 1 && req.limit <= this.limits.maxListLimit + ? req.limit + : null); + if (limit === null) return fail('PATH_INVALID', `limit 必须是 1..${this.limits.maxListLimit}`); + if (req.cursor !== undefined && (typeof req.cursor !== 'string' || req.cursor.length === 0)) { + return fail('PATH_INVALID', 'cursor 必须是非空字符串'); + } + const committed = [...this.durables.values()].sort((a, b) => a.stagingId.localeCompare(b.stagingId)); + let start = 0; + if (typeof req.cursor === 'string') { + const cursor = req.cursor; + start = committed.findIndex((record) => record.stagingId > cursor); + if (start < 0) start = committed.length; + } + const slice = committed.slice(start, start + limit); + const hasMore = start + slice.length < committed.length; + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + return { + ok: true as const, + items: slice.map((record) => ({ ...receiptOf(record), recovery: record.recovery })), + hasMore, + nextCursor: hasMore ? slice[slice.length - 1]!.stagingId : null, + }; + }); + } + + async read(req: { + ghostId: string; + stagingId: unknown; + offset?: unknown; + length?: unknown; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + if (!this.durables.has(stagingId)) return fail('NOT_FOUND', 'stagingId 无效'); + const offset = req.offset === undefined ? 0 : req.offset; + const length = req.length === undefined ? this.limits.maxChunkBytes : req.length; + if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) { + return fail('PATH_INVALID', 'offset 必须是非负整数'); + } + if (typeof length !== 'number' || !Number.isInteger(length) || length < 0) { + return fail('PATH_INVALID', 'length 必须是非负整数'); + } + if (length > this.limits.maxChunkBytes) { + return fail('TOO_LARGE', `读取长度超上限(${this.limits.maxChunkBytes} 字节)`); + } + const read = await this.vault.read({ + path: blobPath(stagingId), + encoding: 'base64', + offset, + length, + }); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (!read.ok) return vaultFail(read); + return { + ok: true as const, + stagingId, + content: read.content, + encoding: 'base64' as const, + bytes: read.bytes, + sha256: read.sha256, + }; + }); + } + + async abort(req: { ghostId: string; stagingId: unknown }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + const upload = this.uploads.get(stagingId); + if (!upload) { + if (this.durables.has(stagingId)) return fail('STREAM_INVALID', '已提交的原件不能 abort'); + return { ok: true as const, aborted: false }; + } + if (upload.commitPending) { + return fail('STREAM_INVALID', '已提交的原件不能 abort'); + } + const aborted = await this.vault.writeAbort({ streamId: upload.streamId }); + this.uploads.delete(stagingId); + this.byTask.delete(taskKey(upload.taskId, upload.sourceRevision)); + const intentDeleted = await this.vault.delete({ path: intentPath(stagingId) }); + if (!intentDeleted.ok && intentDeleted.errorCode !== 'NOT_FOUND') return vaultFail(intentDeleted); + const residue = await this.refreshClosedTmp(); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (!aborted.ok) return vaultFail(aborted); + if (residue) return residue; + return { ok: true as const, aborted: aborted.aborted }; + }); + } + + async dispose(): Promise { + await this.runSerialized(async () => { + for (const upload of [...this.uploads.values()]) { + await this.vault.writeAbort({ streamId: upload.streamId }).catch(() => {}); + } + this.uploads.clear(); + this.byTask.clear(); + await this.vault.invalidate().catch(() => {}); + }); + } + + async release(req: { + ghostId: string; + stagingId: unknown; + ack: LibraryStagingAck; + /** Recheck Library session/epoch and re-hash the unique original before irreversible staging delete. */ + confirmLibrary?: () => LibraryStagingFailure | null | Promise; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + if (req.ack.ok !== true) return req.ack; + const confirm = async (): Promise => { + const owner = this.requireOwner(); + if (owner) return owner; + return (await req.confirmLibrary?.()) ?? null; + }; + const rollbackTombstone = async (): Promise => { + const deleted = await this.vault.delete({ path: tombstonePath(stagingId) }); + if (!deleted.ok && deleted.errorCode !== 'NOT_FOUND') return vaultFail(deleted); + return null; + }; + const record = this.durables.get(stagingId); + if (!record) { + const blocked = await confirm(); + if (blocked) return blocked; + const tomb = await this.readTombstone(stagingId); + const blockedAfter = await confirm(); + if (blockedAfter) return blockedAfter; + if (!tomb.ok) { + return tomb.errorCode === 'NOT_FOUND' + ? { ok: true as const, stagingId, released: false } + : tomb; + } + const cleaned = await this.finishReleaseUnlocked(stagingId); + if (cleaned) return cleaned; + return { ok: true as const, stagingId, released: false }; + } + if (req.ack.sha256 !== record.sha256 || req.ack.bytes !== record.bytes) { + return fail('ACK_MISMATCH', 'Library ACK 与 staging 原件不一致,原件已保留'); + } + const blocked = await confirm(); + if (blocked) return blocked; + const stone = await this.vault.write({ + path: tombstonePath(stagingId), + content: JSON.stringify({ version: 1, stagingId, released: true }), + ifNotExists: true, + }); + const blockedAfterWrite = await confirm(); + if (blockedAfterWrite) { + return await rollbackTombstone() ?? blockedAfterWrite; + } + if (!stone.ok && stone.errorCode !== 'ALREADY_EXISTS') return vaultFail(stone); + const markerSync = await this.fsyncDurablePath(stagingId); + if (markerSync) { + return await rollbackTombstone() ?? markerSync; + } + const blockedAfterSync = await confirm(); + if (blockedAfterSync) { + return await rollbackTombstone() ?? blockedAfterSync; + } + const cleaned = await this.finishReleaseUnlocked(stagingId); + if (cleaned) return cleaned; + this.durables.delete(stagingId); + this.byTask.delete(taskKey(record.taskId, record.sourceRevision)); + this.durableBytes = Math.max(0, this.durableBytes - record.bytes); + return { ok: true as const, stagingId, released: true }; + }); + } +} diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 03d856425a..b257259417 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -148,7 +148,8 @@ export interface LibraryVaultDeps { realPathAtGrant: string; identity: { dev: number; ino: number } | null; }; - log?: { + /** false = binding already created this ghost tree; MISSING must not init an empty replacement. */ + allowCustomInit?: boolean; log?: { info: (msg: string, meta?: Record) => void; warn: (msg: string, meta?: Record) => void; }; @@ -318,6 +319,8 @@ export class LibraryVault { private opened = false; private invalidated = false; + /** Absolute parent dirs created by recursive mkdir of a missing root; fsync these before claiming durable. */ + private createdAncestorDirs: string[] = []; private state: LibraryState = 'ready'; private unavailableReason: string | null = null; private readonlyReason: string | null = null; @@ -361,6 +364,79 @@ export class LibraryVault { return fsFailure(err, 'INTERNAL', message); } + /** mkdir -p that records newly created ancestor dirs (deepest first) so their parent entries can be fsynced. */ + private async mkdirRecordingCreated(absDir: string): Promise { + const target = path.resolve(absDir); + const created: string[] = []; + let cursor = target; + const missing: string[] = []; + for (;;) { + try { + const st = await fs.promises.lstat(cursor); + if (st.isSymbolicLink() || !st.isDirectory()) { + throw Object.assign(new Error('mkdir target is not a directory'), { code: 'ENOTDIR' }); + } + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + missing.push(cursor); + const parent = path.dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + } + for (const dir of missing.reverse()) { + await fs.promises.mkdir(dir); + created.push(dir); + } + return created; + } + + private async fsyncAbsDir(absDir: string): Promise> { + const st = await this.lstatTarget(absDir); + if (!st) return fail('NOT_FOUND', '目录不存在'); + if (st.isSymbolicLink() || !st.isDirectory()) { + return fail('PATH_INVALID', 'fsyncDir 目标必须是普通目录'); + } + if (process.platform === 'win32') { + return { ok: true as const, fsynced: false }; + } + try { + const fh = await fs.promises.open(absDir, 'r'); + try { + await fh.sync(); + } finally { + await fh.close(); + } + return { ok: true as const, fsynced: true }; + } catch (err) { + return this.tmpFailure(err, '目录 fsync 失败'); + } + } + + /** + * Fsync parents of directories created when open() first minted this root. + * Syncing the root inode is not the same as syncing the parent directory entry. + * Windows still reports fsynced:false after the same path checks. + */ + async fsyncCreatedAncestors(): Promise> { + if (!this.opened) return fail('LIBRARY_UNAVAILABLE', 'Library 未打开(先调用 open)'); + if (this.createdAncestorDirs.length === 0) { + return { ok: true as const, fsynced: true }; + } + let anyUnsynced = false; + const parents = new Set(); + for (const created of this.createdAncestorDirs) { + parents.add(path.dirname(created)); + } + for (const parent of parents) { + const synced = await this.fsyncAbsDir(parent); + if (!synced.ok) return synced; + if (synced.fsynced !== true) anyUnsynced = true; + } + if (process.platform !== 'win32' && !anyUnsynced) this.createdAncestorDirs = []; + return { ok: true as const, fsynced: !anyUnsynced }; + } /** Custom roots must not recreate a vanished or replaced user-selected parent. keep files stay in the renamed-away directory. */ private customRootUnavailable( reason: 'disk-missing' | 'binding-moved' = 'disk-missing', @@ -467,7 +543,11 @@ export class LibraryVault { this.meta = existing.meta; if (existing.usage) customUsage = existing.usage; } else if (existing.code === 'MISSING') { - const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ + // Same vault already had a live custom tree, or binding says this + // ghost dir was created before: do not mkdir an empty replacement. + if (this.meta || this.deps.allowCustomInit === false) { + return this.customRootUnavailable('disk-missing'); + } const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ parentFd: parentHandle.fd, ghostId: dirSeg, metaJson, @@ -505,7 +585,7 @@ export class LibraryVault { } } } else { - await fs.promises.mkdir(this.root, { recursive: true }); + this.createdAncestorDirs = await this.mkdirRecordingCreated(this.root); await fs.promises.mkdir(this.tmpDir, { recursive: true }); await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); } @@ -1074,6 +1154,69 @@ export class LibraryVault { }); } + /** Active writeBegin reservations (declared totalBytes). Staging uses this for hard 8GiB accounting. */ + pendingStreamReservationBytes(): number { + let total = 0; + for (const stream of this.streams.values()) total += stream.totalBytes; + return total; + } + + /** Leftover .cindy-library/tmp files. ENOENT = 0; permission/corrupt/symlink fail closed. */ + async tmpResidueBytes(): Promise> { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(this.tmpDir, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { ok: true as const, bytes: 0 }; + } + return this.tmpFailure(err, 'staging 残片目录不可读'); + } + const activeTmp = new Set(Array.from(this.streams.values()).map((stream) => stream.tmpAbs)); + let bytes = 0; + for (const entry of entries) { + const full = path.join(this.tmpDir, entry.name); + if (activeTmp.has(full)) continue; + let st: fs.Stats; + try { + st = await fs.promises.lstat(full); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue; + return this.tmpFailure(err, 'staging 残片不可读'); + } + if (st.isSymbolicLink()) { + return fail('LIBRARY_UNAVAILABLE', 'staging 残片含符号链接'); + } + if (st.isFile()) bytes += st.size; + } + return { ok: true as const, bytes }; + } + + /** + * Fsync a library-relative directory after durable rename. + * Reuses resolveTarget identity (symlink/root guard). POSIX failure is not durable. + * Windows cannot open a directory fd — returns fsynced:false after the same path checks. + */ + async fsyncDir(relPath: unknown = ''): Promise> { + if (!this.opened) return fail('LIBRARY_UNAVAILABLE', 'Library 未打开(先调用 open)'); + const rel = relPath === '' || relPath === undefined || relPath === null ? '' : String(relPath); + let target: string; + if (rel === '') { + try { + target = await fs.promises.realpath(this.root); + } catch { + return fail('LIBRARY_UNAVAILABLE', 'Library 根目录不可访问'); + } + } else { + const reason = validateLibraryRelPath(rel, this.limits); + if (reason) return fail('PATH_INVALID', reason); + const resolved = await this.resolveTarget(rel); + if (!('target' in resolved)) return resolved; + target = resolved.target; + } + return this.fsyncAbsDir(target); + } + async writeAbort(req: { streamId: unknown }): Promise> { const stream = typeof req.streamId === 'string' ? this.streams.get(req.streamId) : undefined; if (!stream) return { ok: true as const, aborted: false }; // 幂等 @@ -1141,7 +1284,7 @@ export class LibraryVault { }); } - async list(req: { path?: unknown; recursive?: unknown; cursor?: unknown; limit?: unknown }): Promise; hasMore: boolean; nextCursor: string | null }>> { + async list(req: { path?: unknown; recursive?: unknown; cursor?: unknown; limit?: unknown; strict?: unknown }): Promise; hasMore: boolean; nextCursor: string | null }>> { if (!this.opened) return fail('LIBRARY_UNAVAILABLE', 'Library 未打开(先调用 open)'); const sub = req.path === undefined || req.path === '' ? '' : String(req.path); if (sub !== '') { @@ -1152,6 +1295,7 @@ export class LibraryVault { const listRoot = sub === '' ? base : path.join(base, ...sub.split('/')); if (!isInsideDir(base, listRoot)) return fail('PATH_INVALID', 'path 越界'); const recursive = req.recursive === true; + const strict = req.strict === true; const limit = Math.min( typeof req.limit === 'number' && Number.isInteger(req.limit) && req.limit > 0 ? req.limit : this.limits.listPageSize, this.limits.listPageSize, @@ -1189,10 +1333,17 @@ export class LibraryVault { let st: fs.Stats; try { st = await fs.promises.lstat(full); - } catch { + } catch (err) { + if (strict && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + return this.tmpFailure(err, '目录条目不可读'); + } continue; } const rel = path.relative(base, full).split(path.sep).join('/'); + if (st.isSymbolicLink()) { + if (strict) return fail('LIBRARY_UNAVAILABLE', '目录含符号链接'); + continue; + } if (st.isFile()) { if (!push(rel, 'file', st.size, Math.round(st.mtimeMs))) break; } else if (st.isDirectory()) { @@ -1448,6 +1599,44 @@ export class LibraryVault { * 相对路径 → 已存在普通文件的绝对路径。路径纪律与 read 同源;任何校验 * 不过(越界/symlink/不存在/是目录)返回 null,调用方统一折叠 404。 */ + /** Stream the whole file for hash+size; never slurps 8GiB into memory. */ + async hashFile(relPath: string): Promise> { + if (!this.opened) return fail('LIBRARY_UNAVAILABLE', 'Library 未打开(先调用 open)'); + const reason = validateLibraryRelPath(relPath, this.limits); + if (reason) return fail('PATH_INVALID', reason); + const resolved = await this.resolveTarget(relPath); + if (!('target' in resolved)) return resolved; + const expected = await this.lstatIdentityForRead(resolved.target); + if (!expected) return fail('NOT_FOUND', `文件不存在:${relPath}`); + if (!expected.isFile()) return fail('PATH_INVALID', `不是文件:${relPath}`); + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0); + let handle: LibraryReadHandle | null = null; + try { + handle = await this.openReadHandle(resolved.target, flags); + const opened = await handle.stat(); + if (!opened.isFile()) return fail('PATH_INVALID', `不是文件:${relPath}`); + if (!isSameLibraryFileObject(expected, opened)) { + return fail('INTERNAL', '读取身份校验失败(目标 identity 不一致)'); + } + const size = Number(opened.size); + const hash = crypto.createHash('sha256'); + const buf = Buffer.alloc(Math.min(1024 * 1024, Math.max(size, 1))); + let pos = 0; + while (pos < size) { + const { bytesRead } = await handle.read(buf, 0, buf.length, pos); + if (bytesRead === 0) break; + hash.update(buf.subarray(0, bytesRead)); + pos += bytesRead; + } + if (pos !== size) return fail('INTERNAL', '读取不完整'); + return { ok: true as const, path: relPath, bytes: size, sha256: hash.digest('hex') }; + } catch (err) { + return this.tmpFailure(err, '读取失败(主机 IO 错误)'); + } finally { + if (handle) await handle.close().catch(() => {}); + } + } + async resolveExistingFile(relPath: string): Promise { const reason = validateLibraryRelPath(relPath, this.limits); if (reason) return null; diff --git a/apps/desktop/src/shared/ghost.ts b/apps/desktop/src/shared/ghost.ts index 36dc2c338c..e5354ed307 100644 --- a/apps/desktop/src/shared/ghost.ts +++ b/apps/desktop/src/shared/ghost.ts @@ -8228,6 +8228,13 @@ export const GHOST_LIBRARY_OPS = [ 'reveal', 'saveAs', 'clipboardWrite', + 'staging.begin', + 'staging.chunk', + 'staging.commit', + 'staging.list', + 'staging.read', + 'staging.release', + 'staging.abort', ] as const; export type GhostLibraryOp = (typeof GHOST_LIBRARY_OPS)[number]; @@ -8235,9 +8242,30 @@ export type GhostLibraryOp = (typeof GHOST_LIBRARY_OPS)[number]; export const GHOST_LIBRARY_CAPABILITY_OPERATIONS = ['clipboardWrite', 'saveAs'] as const; export type GhostLibraryCapabilityOperation = (typeof GHOST_LIBRARY_CAPABILITY_OPERATIONS)[number]; +export const GHOST_LIBRARY_STAGING_OPERATIONS = [ + 'staging.begin', + 'staging.chunk', + 'staging.commit', + 'staging.list', + 'staging.read', + 'staging.release', + 'staging.abort', +] as const; +export type GhostLibraryStagingOperation = (typeof GHOST_LIBRARY_STAGING_OPERATIONS)[number]; + +export const GHOST_LIBRARY_STAGING_LIMITS_V1 = { + version: 1 as const, + maxTaskBytes: 8 * 1024 * 1024 * 1024, + maxTotalBytes: 8 * 1024 * 1024 * 1024, + maxConcurrentWrites: 4, + maxChunkBytes: 16 * 1024 * 1024, + reserveBytes: 1024 * 1024 * 1024, +}; + export const GHOST_LIBRARY_CAPABILITIES_V1 = { version: 1 as const, - operations: GHOST_LIBRARY_CAPABILITY_OPERATIONS, + operations: [...GHOST_LIBRARY_CAPABILITY_OPERATIONS, ...GHOST_LIBRARY_STAGING_OPERATIONS] as const, + staging: GHOST_LIBRARY_STAGING_LIMITS_V1, }; /** 宿主实际操作的稳定失败类别;TIMEOUT / TRANSPORT_ERROR 由插件查询层本地分类,不从 message 猜测。 */ @@ -8314,6 +8342,16 @@ export interface GhostPipeLibraryRequest { length?: number; /** saveAs: 另存为建议文件名(仅 basename)。 */ name?: string; + /** staging: 插件任务身份 / 源版本 / MIME / 恢复元数据。 */ + taskId?: string; + sourceRevision?: string; + mime?: string; + recovery?: Record; + stagingId?: string; + /** staging.release: Library ACK 字节数(不是 begin 的 totalBytes)。 */ + bytes?: number; + libraryIdentity?: string; + libraryGeneration?: number; } /** @@ -8394,9 +8432,57 @@ export type GhostPipeLibraryResult = op: 'capabilities'; capabilities: { version: 1; - operations: GhostLibraryCapabilityOperation[]; + operations: ReadonlyArray; + staging?: { + version: 1; + maxTaskBytes: number; + maxTotalBytes: number; + maxConcurrentWrites: number; + maxChunkBytes: number; + reserveBytes: number; + }; }; } + | { ok: true; op: 'staging.begin'; stagingId: string } + | { ok: true; op: 'staging.chunk'; accepted: number } + | { + ok: true; + op: 'staging.commit'; + stagingId: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + durable: true; + } + | { + ok: true; + op: 'staging.list'; + items: Array<{ + stagingId: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + durable: true; + recovery: Record; + }>; + hasMore: boolean; + nextCursor: string | null; + } + | { + ok: true; + op: 'staging.read'; + stagingId: string; + content: string; + encoding: 'base64'; + bytes: number; + sha256: string; + } + | { ok: true; op: 'staging.release'; stagingId: string; released: boolean } + | { ok: true; op: 'staging.abort'; aborted: boolean } | { ok: false; errorCode: string; message: string; reason?: GhostLibraryErrorReason }; /** Library 概览(ghosts:library-overview IPC 载荷;设置页插件详情消费)。 */ diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index c2ece161e0..1e1a2bf77c 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -21,6 +21,7 @@ | per-plugin worker 入口 | `apps/desktop/src/main/cindy-brain/libraryDbWorker.ts` | | 主进程 RPC 服务(发送前语句门 / dispose 收口) | `apps/desktop/src/main/cindy-brain/librarySqlService.ts` | | 协议分派(资格审 / binding 根解析 / owner scope 复核) | `apps/desktop/src/main/cindy-brain/librarySlot.ts` | +| owner-scoped staging(独立于可迁移 Library 根) | `apps/desktop/src/main/cindy-brain/libraryStaging.ts` | | 随时迁移状态机 | `apps/desktop/src/main/cindy-brain/libraryMigrate.ts` | | 回收站删除通道 | `apps/desktop/src/main/cindy-brain/libraryTrash.ts` | | 管子协议类型(`library-request`) | `apps/desktop/src/shared/ghost.ts`(`GhostPipeLibraryRequest/Result`) | @@ -34,7 +35,8 @@ owners// ├── libraries// # 系统管理默认根 ├── libraries-binding.json # 自定义位置持久 binding(原子写) -└── libraries-trash/-/ # 删除通道的 30 天回收站 +├── libraries-trash/-/ # 删除通道的 30 天回收站 +└── library-staging// # H1 上传 staging(不可随 Library 根迁移) ``` 自定义根 = `<用户所选父目录>/`(binding 记录 realpath 快照 + 文件 @@ -113,8 +115,14 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 13. **只读操作能力合同(capabilities)**:`{op:'capabilities'}` 在资格审与 op 合法性 校验之后、会话创建之前返回,不捕获 owner、不解析库根、不 open vault、不弹窗、 不碰剪贴板、不泄漏 owner 或绝对库路径。成功形态固定为 - `{ok:true, op:'capabilities', capabilities:{version:1, operations:['clipboardWrite','saveAs']}}`。 + `{ok:true, op:'capabilities', capabilities:{version:1, operations:[...clipboardWrite/saveAs, staging.*], staging:{version:1,...limits}}}`。 `operations` 只表达**实现支持**,不等于此刻有窗口、已授权或库可用。 + staging 操作与 capabilities 在 Library open/root/authorizedReadonly 门之前分派, + 仍验当前 owner 与已启用 library 能力;只有 `staging.release` 核验当前 Library ACK。 + `disposeGhost` / `disposeAll` 先置 `relocating` 再排空该 ghost 在途 `staging.release`(tombstone/fsync 期间 Library 会话保持稳定),新的 release 在闸上拒绝;bind/unbind/relocate/delete 都先置 relocating 再 dispose,不把 owner mutation lease 当迁库锁。首次 mint staging 根时,耐久还要 fsync 新建根在其父目录中的 entry,只 fsync 根 inode 不算。Windows 仍报 `fsynced:false`。 + staging 根按 owner×ghost 捕获后不漂移;坏/不可读 manifest 返回 `LIBRARY_UNAVAILABLE`,不得报空或释放对应空间。 + 新原件在 blob 就位前经 Vault 写下 `intent.json`(owner/ghost/id/task/revision/hash/bytes/mime/recovery);崩溃后 new Store 只从这份可信 intent 校验 bytes/hash 再补 `manifest.json`+dirfsync,不从 blob 猜归属。不完整或冲突 fail-closed 并保留源。本 PR 新原件必有 intent;历史无 intent 的 orphan blob 只隔离计费,不 TTL 删除、不声称可恢复。原件只在 Library ACK 且调用者已保存画布后释放。 + 恢复与 release 走流式 hash,禁止 `readFile` 整文件入内存。null owner 拒。 消费规则:仅 `version===1` 且 `operations` 为字符串数组才有效;额外字段忽略,未知 operation 忽略,已知项保留;有效 v1 清单缺少某项才是 unsupported;缺字段、错类型、 `version` 非 1、或旧宿主 unknown-op 一律 unknown。旧插件无需重装或重授权。