Files
maestro/src/engine/tools/conflict-guard.ts
T
oss-sync b1292e34b2
CI / build-and-test (push) Has been cancelled
sync: update from private repo (edc775f2)
2026-07-06 01:04:12 +00:00

126 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}