From af8c1795bd12a8d9066e64804b01026f237d1d9e Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 15 Jul 2026 09:25:21 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20VFS=20rename=20moves=20the=20containment?= =?UTF-8?q?=20edge=20=E2=80=94=20no=20ghost=20in=20the=20old=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cross-directory rename added the new parent's Contains edge but skipped removing the old one (a 'not critical' shortcut), leaving the moved entity a child of BOTH directories: readdir(oldDir) kept listing it, re-creating the old path showed the name twice, and tree-walking consumers saw the file in two places. Reported live by a production deployment (37 stale containment ghosts; blocks tree-sync integrations). - rename() now removes the old parent's vfs containment edge(s) by edge id resolved from the graph's own adjacency (the removal law: removal never requires reading the removed thing), and a move to the root gets its containment edge (previously skipped -> orphaned from readdir('/')). - New vfs.repairContainment(): reconciles every VFS entity's containment edges against canonical metadata.path — removes stale/duplicate vfs edges, restores missing ones, never touches user knowledge edges. Wired into repairIndex() so the one operator ritual heals existing ghosts. - Regression: tests/integration/vfs-rename-containment.test.ts (7). --- src/brainy.ts | 15 ++ src/vfs/VirtualFileSystem.ts | 111 ++++++++++++- .../vfs-rename-containment.test.ts | 157 ++++++++++++++++++ 3 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 tests/integration/vfs-rename-containment.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index a9d2b656..ef48c216 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -15301,6 +15301,21 @@ export class Brainy implements BrainyInterface { await pruner.rebuildTypeCounts?.() await pruner.rebuildSubtypeCounts?.() + // VFS containment reconciliation: heal "cosmetic ghost" edges left by + // pre-fix renames (an entity Contains-linked from BOTH its old and new + // directory — readdir listed it in two places) and duplicate edges from + // concurrent writers. Canonical metadata.path is the truth; only VFS + // containment edges are touched. Loud per repair. + if (this._vfsInitialized && this._vfs) { + const containment = await this._vfs.repairContainment() + if (containment.removed + containment.restored > 0) { + prodLog.warn( + `[Brainy] repairIndex() reconciled VFS containment: removed ${containment.removed} ` + + `stale/duplicate edge(s), restored ${containment.restored} missing edge(s).` + ) + } + } + await this.metadataIndex.detectAndRepairCorruption() // Lift a failed-rollback write-quarantine: force a full rebuild so the // derived indexes are provably reconciled with canonical, then clear the diff --git a/src/vfs/VirtualFileSystem.ts b/src/vfs/VirtualFileSystem.ts index 013f04f3..00bddefb 100644 --- a/src/vfs/VirtualFileSystem.ts +++ b/src/vfs/VirtualFileSystem.ts @@ -1954,15 +1954,27 @@ export class VirtualFileSystem implements IVirtualFileSystem { const newParentPath = this.getParentPath(newPath) if (oldParentPath !== newParentPath) { - // Remove from old parent + // Remove the OLD parent's containment edge(s) — by edge id, resolved from + // the graph's own adjacency (the removal law: a removal never requires + // reading the thing being removed). This step used to be skipped as "not + // critical", which left the moved entity a child of BOTH directories: + // readdir(oldDir) kept listing it, re-creating the old path showed the + // name twice, and tree-walking consumers saw the file in two places. if (oldParentPath) { const oldParentId = await this.pathResolver.resolve(oldParentPath) - // unrelate takes the relation ID, not params - need to find and remove relation - // For now, skip unrelate as it's not critical for rename + const staleEdges = await this.brain.related({ + from: oldParentId, + to: entityId, + type: VerbType.Contains + }) + for (const edge of staleEdges) { + await this.brain.unrelate(edge.id) + } } - // Add to new parent - if (newParentPath && newParentPath !== '/') { + // Add to the new parent. The root ('/') is a REAL parent — skipping it + // orphaned a move-to-root out of readdir('/') entirely. + if (newParentPath) { const newParentId = await this.pathResolver.resolve(newParentPath) await this.brain.relate({ from: newParentId, @@ -2003,6 +2015,95 @@ export class VirtualFileSystem implements IVirtualFileSystem { this.triggerWatchers(newPath, 'rename') } + /** + * Reconcile every VFS entity's containment edges against its canonical + * `metadata.path` — the path is the truth (maintained by write/rename); the + * `Contains` edges are a projection of it. Heals the "cosmetic ghost" class + * left by pre-fix renames that added the new parent's edge without removing + * the old one (an entity listed in TWO directories; a re-created old path + * showing its name twice), plus duplicate edges from the same parent left by + * concurrent writers. + * + * CONSERVATIVE by design: only VFS containment edges (subtype + * `'vfs-contains'` or `metadata.isVFS`) are ever touched — a user's own + * knowledge-graph `Contains` edge between the same entities is never + * removed. An entity whose expected parent path has no entity is logged + * loudly and left alone (never orphaned further). Operator-invoked via + * `brain.repairIndex()`. + * + * The canonical pagination walk (not an index query) is deliberate: this is + * a repair op — the projections are the thing under suspicion, so the walk + * reads the source of truth. + * + * @returns Counts of stale edges removed and missing expected edges restored. + */ + async repairContainment(): Promise<{ removed: number; restored: number }> { + await this.ensureInitialized() + + // Pass 1: canonical walk → every VFS entity's id + path. + const idByPath = new Map() + const vfsEntities: Array<{ id: string; path: string }> = [] + let cursor: string | undefined + for (;;) { + const page = await (this.brain as any).storage.getNounsWithPagination({ limit: 500, cursor }) + for (const noun of page.items) { + const meta = (noun as any).metadata ?? noun + const p = meta?.path + if (meta?.vfsType && typeof p === 'string') { + idByPath.set(p, noun.id) + vfsEntities.push({ id: noun.id, path: p }) + } + } + if (!page.hasMore) break + cursor = page.nextCursor + } + + let removed = 0 + let restored = 0 + for (const { id, path } of vfsEntities) { + if (path === '/') continue // the root has no parent + const expectedParentId = idByPath.get(this.getParentPath(path)) + if (!expectedParentId) { + console.warn( + `[VFS] repairContainment: no entity found for parent of ${path} — leaving its edges untouched.` + ) + continue + } + + const incoming = await this.brain.related({ to: id, type: VerbType.Contains }) + let expectedSeen = false + for (const edge of incoming) { + const isVfsEdge = edge.subtype === 'vfs-contains' || (edge.metadata as any)?.isVFS === true + if (!isVfsEdge) continue // never touch user knowledge edges + if (edge.from === expectedParentId && !expectedSeen) { + expectedSeen = true // keep exactly one correct edge + continue + } + // Stale parent (a pre-fix rename ghost) or a duplicate of the correct + // edge (concurrent-writer artifact) — remove it, loudly. + await this.brain.unrelate(edge.id) + removed++ + console.warn( + `[VFS] repairContainment: removed ${edge.from === expectedParentId ? 'duplicate' : 'stale'} ` + + `containment edge ${edge.from} -> ${id} (${path})` + ) + } + if (!expectedSeen) { + await this.brain.relate({ + from: expectedParentId, + to: id, + type: VerbType.Contains, + subtype: 'vfs-contains', + metadata: { isVFS: true } + }) + restored++ + console.warn(`[VFS] repairContainment: restored missing containment edge for ${path}`) + } + } + + return { removed, restored } + } + /** * Copy a file or directory to a new path. * diff --git a/tests/integration/vfs-rename-containment.test.ts b/tests/integration/vfs-rename-containment.test.ts new file mode 100644 index 00000000..0169bf01 --- /dev/null +++ b/tests/integration/vfs-rename-containment.test.ts @@ -0,0 +1,157 @@ +/** + * @module tests/integration/vfs-rename-containment + * @description A cross-directory rename must MOVE the containment edge, not + * accumulate one per parent. The pre-fix rename added the new parent's + * Contains edge but skipped removing the old one ("not critical") — leaving + * the entity a child of BOTH directories: readdir(oldDir) kept listing it, + * re-creating the old path showed the name twice (the duplicate-readdir / + * "cosmetic ghost" field report), and tree-walking consumers saw the file in + * two places. Also covers the repair sweep (repairIndex → vfs.repairContainment) + * that heals ghosts left by earlier versions, and move-to-root (whose edge + * used to be skipped entirely). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Brainy, VerbType } from '../../src/index.js' + +describe('VFS rename moves the containment edge (no ghost in the old directory)', () => { + let brain: any + + beforeEach(async () => { + process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true, dimensions: 384 }) + await brain.init() + await brain.vfs.mkdir('/a', { recursive: true }) + await brain.vfs.mkdir('/b', { recursive: true }) + }) + afterEach(async () => { + await brain.close?.().catch(() => {}) + }) + + it('cross-directory move: old dir stops listing it, new dir lists it exactly once', async () => { + await brain.vfs.writeFile('/a/x.txt', 'v1') + await brain.vfs.rename('/a/x.txt', '/b/x.txt') + + expect(await brain.vfs.readdir('/a')).toEqual([]) + expect(await brain.vfs.readdir('/b')).toEqual(['x.txt']) + expect(String(await brain.vfs.readFile('/b/x.txt'))).toBe('v1') + }) + + it('re-creating the old path lists each name exactly once in each directory', async () => { + await brain.vfs.writeFile('/a/x.txt', 'moved away') + await brain.vfs.rename('/a/x.txt', '/b/x.txt') + await brain.vfs.writeFile('/a/x.txt', 'new file at old path') + + const a = (await brain.vfs.readdir('/a')) as string[] + const b = (await brain.vfs.readdir('/b')) as string[] + expect(a).toEqual(['x.txt']) // exactly once — the pre-fix ghost made this list the moved entity too + expect(b).toEqual(['x.txt']) + expect(String(await brain.vfs.readFile('/a/x.txt'))).toBe('new file at old path') + expect(String(await brain.vfs.readFile('/b/x.txt'))).toBe('moved away') + }) + + it('repeated moves never accumulate containment edges', async () => { + await brain.vfs.writeFile('/a/f.txt', 'wanderer') + for (let i = 0; i < 3; i++) { + await brain.vfs.rename('/a/f.txt', '/b/f.txt') + await brain.vfs.rename('/b/f.txt', '/a/f.txt') + } + expect(await brain.vfs.readdir('/a')).toEqual(['f.txt']) + expect(await brain.vfs.readdir('/b')).toEqual([]) + + // Exactly ONE containment edge exists on the entity. + const stat = await brain.vfs.stat('/a/f.txt') + const edges = await brain.related({ to: stat.entityId, type: VerbType.Contains }) + expect(edges).toHaveLength(1) + }) + + it('move to the root gets a containment edge (used to be skipped → orphan)', async () => { + await brain.vfs.writeFile('/a/up.txt', 'to the top') + await brain.vfs.rename('/a/up.txt', '/up.txt') + + const rootListing = (await brain.vfs.readdir('/')) as string[] + expect(rootListing).toContain('up.txt') + expect(await brain.vfs.readdir('/a')).toEqual([]) + expect(String(await brain.vfs.readFile('/up.txt'))).toBe('to the top') + }) +}) + +describe('repairIndex() heals pre-fix containment ghosts (vfs.repairContainment)', () => { + let brain: any + + beforeEach(async () => { + process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true, dimensions: 384 }) + await brain.init() + await brain.vfs.mkdir('/a', { recursive: true }) + await brain.vfs.mkdir('/b', { recursive: true }) + }) + afterEach(async () => { + await brain.close?.().catch(() => {}) + }) + + /** Reproduce the PRE-FIX defect state: file lives at /b/g.txt but a stale + * vfs-contains edge from /a lingers (what old renames left behind). */ + const synthesizeGhost = async () => { + await brain.vfs.writeFile('/b/g.txt', 'ghost target') + const fileId = (await brain.vfs.stat('/b/g.txt')).entityId + const aId = (await brain.vfs.stat('/a')).entityId + await brain.relate({ + from: aId, + to: fileId, + type: VerbType.Contains, + subtype: 'vfs-contains', + metadata: { isVFS: true } + }) + return { fileId, aId } + } + + it('a stale old-parent edge is removed; listings become honest', async () => { + const { fileId } = await synthesizeGhost() + // The defect state is visible: /a lists a file whose path says /b. + expect((await brain.vfs.readdir('/a')) as string[]).toContain('g.txt') + + await brain.repairIndex() + + expect(await brain.vfs.readdir('/a')).toEqual([]) + expect(await brain.vfs.readdir('/b')).toEqual(['g.txt']) + const edges = await brain.related({ to: fileId, type: VerbType.Contains }) + expect(edges).toHaveLength(1) + }) + + it("a user's own knowledge Contains edge onto the file survives the repair", async () => { + const { fileId } = await synthesizeGhost() + // A knowledge-graph containment from a NON-directory entity (a collection + // curating the file) — same verb TYPE, not a vfs-contains edge. The repair + // must remove only VFS containment ghosts, never user knowledge edges. + // (Edges dedupe by (from,to,type), so the user edge needs its own source.) + const collectionId = await brain.add({ + data: 'reading list', + type: 'collection', + metadata: { kind: 'curation' } + }) + await brain.relate({ from: collectionId, to: fileId, type: VerbType.Contains, subtype: 'curates' }) + + await brain.repairIndex() + + const edges = await brain.related({ to: fileId, type: VerbType.Contains }) + const subtypes = edges.map((e: any) => e.subtype).sort() + // The stale vfs ghost edge is gone; the correct vfs edge + the user's + // curation edge remain. + expect(subtypes).toEqual(['curates', 'vfs-contains']) + expect(edges.find((e: any) => e.subtype === 'curates')?.from).toBe(collectionId) + }) + + it('a missing expected edge is restored (entity unreachable from its own directory)', async () => { + await brain.vfs.writeFile('/b/lost.txt', 'find me') + const fileId = (await brain.vfs.stat('/b/lost.txt')).entityId + // Simulate total edge loss (an older damage shape). + for (const e of await brain.related({ to: fileId, type: VerbType.Contains })) { + await brain.unrelate(e.id) + } + expect((await brain.vfs.readdir('/b')) as string[]).not.toContain('lost.txt') + + await brain.repairIndex() + + expect((await brain.vfs.readdir('/b')) as string[]).toContain('lost.txt') + }) +})