126 lines
5.9 KiB
TypeScript
126 lines
5.9 KiB
TypeScript
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<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;
|
||
/** 競合時に退避した旧ファイルの相対パス */
|
||
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 };
|
||
}
|