fix: VFS rename moves the containment edge — no ghost in the old directory

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).
This commit is contained in:
David Snelling 2026-07-15 09:25:21 -07:00
parent 1a3a493c27
commit af8c1795bd
3 changed files with 278 additions and 5 deletions

View file

@ -15301,6 +15301,21 @@ export class Brainy<T = any> implements BrainyInterface<T> {
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

View file

@ -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<string, string>()
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.
*