// v2.8 In-memory conflict index — tracks which worktrees/agents are editing // which files so the collision detector can find overlaps. // // Singleton exported as `conflictIndex`; imported by pending_changes.ts to // register changes at queue time and unregister on worktree teardown. // // NOT persisted — survives only as long as the BooCoder process. Postgres // is the durable record (pending_changes table); this is the hot in-memory // probe for concurrent edit warnings. import type { ConflictEntry, ConflictVerdict } from './collision-detector.js'; import { findConflicts } from './collision-detector.js'; export class ConflictIndex { /** * filePath → Set of ConflictEntry from various worktrees. * A single worktree may have multiple entries for the same file * (several pending edits to the same file in one session). */ #map = new Map>(); // ---- mutation ------------------------------------------------------- /** * Register that `worktreeId` (agent) is touching `filePath`. * Creates an entry in the index so subsequent callers see it as a conflict. */ registerChange( filePath: string, worktreeId: string, agent: string, lineRange?: { start: number; end: number }, ): void { let entries = this.#map.get(filePath); if (!entries) { entries = new Set(); this.#map.set(filePath, entries); } entries.add({ worktreeId, agent, lineRange, status: 'pending' as const, timestamp: Date.now(), }); } /** * Remove all entries for a given worktree. Called on worktree teardown * so stale entries don't trigger false warnings. */ removeWorktree(worktreeId: string): void { for (const [filePath, entries] of this.#map) { const before = entries.size; for (const entry of entries) { if (entry.worktreeId === worktreeId) { entries.delete(entry); } } if (entries.size === 0) { this.#map.delete(filePath); } } } /** * Remove entries older than `maxAgeMs`. Useful as a periodic cleanup * when worktree teardown was missed (crash, unclean exit). */ sweepStale(maxAgeMs: number): number { const cutoff = Date.now() - maxAgeMs; let removed = 0; for (const [filePath, entries] of this.#map) { for (const entry of entries) { if (entry.timestamp < cutoff) { entries.delete(entry); removed++; } } if (entries.size === 0) { this.#map.delete(filePath); } } return removed; } // ---- query ---------------------------------------------------------- /** * Query the raw ConflictEntry set for a file path. Returns empty set * when there are no entries (never mutated the file). */ getEntriesFor(filePath: string): ReadonlySet { return this.#map.get(filePath) ?? new Set(); } /** * Get all conflict verdicts for a given file path — which other * worktrees are touching it. Returns empty when only one worktree * has entries (no actual conflict). */ getConflictsFor(filePath: string): ConflictVerdict[] { const entries = this.#map.get(filePath); if (!entries || entries.size === 0) return []; // Determine distinct worktree IDs. If only one, no conflict. const worktreeIds = new Set(); for (const e of entries) worktreeIds.add(e.worktreeId); if (worktreeIds.size <= 1) return []; // Use the first worktree as the "caller" so findConflicts excludes // its entries and returns only entries from OTHER worktrees. const caller = [...worktreeIds][0]!; return findConflicts( [filePath], caller, new Map(), this.#toIndexData(), ); } /** * Get conflicts for a set of file changes from a specific worktree. * Delegates to the pure findConflicts function. */ query( changedFiles: string[], worktreeId: string, changedRanges: Map, ): ConflictVerdict[] { return findConflicts(changedFiles, worktreeId, changedRanges, this.#toIndexData()); } /** * Snapshot the current map for testing/inspection. */ snapshot(): Map> { return new Map(this.#map); } // ---- private -------------------------------------------------------- #toIndexData(): ReadonlyMap> { return this.#map as ReadonlyMap>; } } // Singleton — the whole BooCoder process shares one conflict index. export const conflictIndex = new ConflictIndex();