sync: update from private repo (dfadcd5f)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-23 06:38:48 +00:00
parent 6a2f2cc736
commit 29ccaf1e92
377 changed files with 31028 additions and 8994 deletions
+121
View File
@@ -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 };
}