import { createHash } from 'node:crypto'; import { constants, copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, statSync, unlinkSync, } from 'node:fs'; import { join, dirname, extname } from 'node:path'; /** * 競合台帳(計画5 / spec §10.3)。 * * 台帳は files ツリーの外 — スペース単位の `.conflict/file-versions.json` — に住む。 * Phase A 以前は `logs/.file-versions.json` で、logs/** を除外する特別扱いが必要だった。 * 台帳が files の外に出たので、その除外ロジックは撤廃した(logs 配下も普通に version 記録される)。 * * アトミック性(同一プロセス内の複数エージェント並行を前提とした堅牢化): * - 台帳書込は temp ファイル → `renameSync`(rename は同一 FS でアトミック)。 * read-modify-write 自体は同期実行(better-sqlite3 / tools は単一スレッド同期)なので、 * 一回の呼び出し内では中断されない。temp+rename により、別エージェントの並行書込で * 半端な JSON を観測したり last-writer-wins でキーを取りこぼす窓を除去する。 * - 旧版退避先は `copyFileSync(..., COPYFILE_EXCL)` で確保してから元ファイルを消す。 * これにより、二者が同じ base を同時解決しても old/ 内の既存ファイルを上書きしない。 * * 残存制限(spec §10.3): per-file の書込ロックは持たない。同一成果物への真の同時上書きは * last-writer-wins のまま。台帳のアトミック性とは別レイヤーの課題。 */ function hash(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex'); } function ledgerPath(conflictDir: string): string { return join(conflictDir, 'file-versions.json'); } function readLedger(conflictDir: string): Record { try { return JSON.parse(readFileSync(ledgerPath(conflictDir), 'utf-8')); } catch { return {}; } } /** 台帳を temp ファイル経由でアトミックに書く(rename は同一 FS でアトミック)。 */ function writeLedger(conflictDir: string, led: Record): void { mkdirSync(conflictDir, { recursive: true }); const target = ledgerPath(conflictDir); const tmp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; writeFileSync(tmp, JSON.stringify(led), 'utf-8'); renameSync(tmp, target); } /** * 現在のディスク版を台帳に記録する(Read / Edit / 正常 Write の後に呼ぶ)。 * ベストエフォート: 記録に失敗しても **絶対に throw しない**。これはツールの * 副次的な簿記であり、対象が存在しない / ディレクトリ / 権限不足 / レース等で * readFileSync が EISDIR/ENOENT 等を投げても、ジョブをクラッシュさせてはならない * (未記録なら後続 Write が競合を検知できないだけで、安全側に縮退する)。 */ export function recordVersion(ws: string, relPath: string, conflictDir: string): void { try { const abs = join(ws, relPath); // 通常ファイルのみ版管理する。ディレクトリ/特殊ファイルは readFileSync が // 投げる(EISDIR 等)ので明示的にスキップする。statSync は欠損で throw → catch。 if (!statSync(abs).isFile()) return; const led = readLedger(conflictDir); led[relPath] = hash(readFileSync(abs)); writeLedger(conflictDir, led); } catch { // best-effort: 簿記の失敗でツール/ジョブを落とさない。 } } export interface WriteResolution { /** 実際に書き込むべき相対パス */ targetRelPath: string; /** 競合時に退避した旧ファイルの相対パス */ archivedRelPath?: string; conflict: boolean; } /** * 既存ファイルを同じ階層の old/ へ退避する。退避名は `{stem}_oldN{ext}`。 * COPYFILE_EXCL により、既存の退避ファイルを上書きしない。 */ function archiveExistingFile(ws: string, relPath: string): string { const ext = extname(relPath); const baseName = relPath.slice(relPath.lastIndexOf('/') + 1); const stem = baseName.slice(0, baseName.length - ext.length); const parent = dirname(relPath); const oldDir = parent === '.' ? 'old' : join(parent, 'old'); const sourceAbs = join(ws, relPath); // 同一プロセス内の暴走に対する上限(実用上は到達しない)。 for (let n = 1; n < 100000; n++) { const candidate = join(oldDir, `${stem}_old${n}${ext}`); const abs = join(ws, candidate); try { mkdirSync(dirname(abs), { recursive: true }); copyFileSync(sourceAbs, abs, constants.COPYFILE_EXCL); unlinkSync(sourceAbs); return candidate; } catch (e) { if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue; throw e; } } throw new Error('archiveExistingFile: exhausted candidate names'); } /** * Write の衝突解決(spec §2.4)。 * - 対象が存在しない → そのまま書く(競合なし)。 * - 対象が存在し、ディスク版が台帳の記録と一致 → 普通に上書き(競合なし)。 * - 対象が存在するが台帳に記録が無い、または記録と食い違う → 競合。 * 既存ファイルを同じ階層の `old/{name}_oldN.{ext}` へ退避し、元パスへ新しい内容を書く。 */ export function resolveWriteTarget( ws: string, relPath: string, _newContent: string, conflictDir: string, ): WriteResolution { const abs = join(ws, relPath); if (!existsSync(abs)) return { targetRelPath: relPath, conflict: false }; const led = readLedger(conflictDir); const base = led[relPath]; const diskHash = hash(readFileSync(abs)); if (base && base === diskHash) return { targetRelPath: relPath, conflict: false }; const archivedRelPath = archiveExistingFile(ws, relPath); return { targetRelPath: relPath, archivedRelPath, conflict: true }; }