This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync, readFileSync, writeFileSync, mkdirSync, openSync, closeSync, renameSync, statSync,
|
||||
} 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 でキーを取りこぼす窓を除去する。
|
||||
* - 競合コピー名は `openSync(path, 'wx')`(O_EXCL)で予約してから書く。`while(existsSync)`
|
||||
* スキャンの TOCTOU を排除し、二者が同じ base を同時解決しても別名を得る。
|
||||
*
|
||||
* 残存制限(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<string, string> {
|
||||
try { return JSON.parse(readFileSync(ledgerPath(conflictDir), 'utf-8')); } catch { return {}; }
|
||||
}
|
||||
|
||||
/** 台帳を temp ファイル経由でアトミックに書く(rename は同一 FS でアトミック)。 */
|
||||
function writeLedger(conflictDir: string, led: Record<string, string>): 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;
|
||||
conflict: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 競合コピー名を O_EXCL で予約する。`{stem} (競合コピー N){ext}` を N=1 から順に試し、
|
||||
* 排他作成に成功した最初の名前を返す。空ファイルを予約として作るので、呼び出し側は
|
||||
* その名前へ後から書き込む(盲目上書きの窓が無い)。
|
||||
*/
|
||||
function reserveCompetingCopy(ws: string, relPath: string): string {
|
||||
const ext = extname(relPath);
|
||||
const stem = relPath.slice(0, relPath.length - ext.length);
|
||||
// 同一プロセス内の暴走に対する上限(実用上は到達しない)。
|
||||
for (let n = 1; n < 100000; n++) {
|
||||
const candidate = `${stem} (競合コピー ${n})${ext}`;
|
||||
const abs = join(ws, candidate);
|
||||
try {
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
const fd = openSync(abs, 'wx'); // O_EXCL: 既存なら EEXIST
|
||||
closeSync(fd);
|
||||
return candidate;
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw new Error('reserveCompetingCopy: exhausted candidate names');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write の衝突解決(spec §2.4)。
|
||||
* - 対象が存在しない → そのまま書く(競合なし)。
|
||||
* - 対象が存在し、ディスク版が台帳の記録と一致 → 普通に上書き(競合なし)。
|
||||
* - 対象が存在するが台帳に記録が無い、または記録と食い違う → 競合。
|
||||
* O_EXCL で予約した `{name} (競合コピー N).{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 };
|
||||
// 競合: O_EXCL で空いている連番のコピー名を予約する(TOCTOU 排除)。
|
||||
const candidate = reserveCompetingCopy(ws, relPath);
|
||||
return { targetRelPath: candidate, conflict: true };
|
||||
}
|
||||
Reference in New Issue
Block a user