FileLedger merkt je Agent die gelesene Fassung einer Datei in gemeinsamen Bereichen. write, copy und Downloads (FTP, RocketChat) ersetzen eine Datei nur, wenn sie seit dem Lesen unveraendert ist; eine fremde Datei nicht ungelesen. append bleibt ungeprueft, macht aeltere Lesestaende aber ungueltig. Abgelehnte Schreibvorgaenge: eigener Audit-Status Conflict, Tabelle FileConflicts, Hotspots (>= 3 in 7 Tagen) in der Teamuebersicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
11 KiB
C#
233 lines
11 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using ClawdDotNet.Core.Storage;
|
|
|
|
namespace ClawdDotNet.Core.Files;
|
|
|
|
/// <summary>#720: A write that was refused because it would have lost someone's change.</summary>
|
|
public sealed record FileConflict(
|
|
string Path,
|
|
string Workspace,
|
|
string AgentId,
|
|
string? RunId,
|
|
string? OtherAgentId,
|
|
string? OtherRunId,
|
|
string Kind,
|
|
DateTime OccurredAtUtc)
|
|
{
|
|
/// <summary>The file changed after this agent read it.</summary>
|
|
public const string ChangedSinceRead = "changed-since-read";
|
|
|
|
/// <summary>Another agent wrote the file, and this one never read it.</summary>
|
|
public const string NotRead = "not-read";
|
|
|
|
/// <summary>What the agent is told — what happened, and what to do instead.</summary>
|
|
public string Message(string displayPath)
|
|
{
|
|
var who = OtherAgentId is null ? "someone else" : $"agent '{OtherAgentId}'";
|
|
return Kind == NotRead
|
|
? $"Conflict: {displayPath} was last written by {who}, and you have not read it. " +
|
|
"Read it first and make your change on its current content — or use 'append' if you only add to it."
|
|
: $"Conflict: {displayPath} was changed by {who} since you read it. Your write would overwrite that change. " +
|
|
"Read it again and make your change on the current content — or use 'append' if you only add to it.";
|
|
}
|
|
}
|
|
|
|
/// <summary>#720: A file that keeps running into conflicts — the candidate for a different kind of storage.</summary>
|
|
public sealed record FileConflictHotspot(string Path, string Workspace, int Conflicts, DateTime LastAtUtc, IReadOnlyList<string> Agents);
|
|
|
|
/// <summary>
|
|
/// #720: Lost updates in shared workspaces, found instead of waited for.
|
|
///
|
|
/// <para><b>Optimistic, not locked.</b> A lock held across model steps lasts minutes, is left
|
|
/// behind when a run breaks off, and makes the waiting agents burn steps. Instead the
|
|
/// instance remembers which version of a file each agent read. A write that replaces a file
|
|
/// compares the file as it is now with that version; if someone changed it in between, the
|
|
/// write is refused and the agent reads again. The agent needs no parameter for this, so it
|
|
/// holds for a cheap model that would forget one.</para>
|
|
///
|
|
/// <para><b>What is remembered where.</b> Reads and last writers live in memory: after a
|
|
/// restart there is no run that could build on an earlier read, and a file whose writer is
|
|
/// unknown may be overwritten as before. Conflicts are kept in the database — they are what
|
|
/// shows which files should become something else (split per agent, append-only, a table).</para>
|
|
///
|
|
/// <para>Appending is not checked: it loses nothing. It is noted, though — whoever read the
|
|
/// file before the append does not know the appended part, and a later replace by them is a
|
|
/// conflict. That is the point.</para>
|
|
/// </summary>
|
|
public sealed class FileLedger
|
|
{
|
|
private sealed record Mark(string Hash, string? RunId, DateTime AtUtc, string AgentId);
|
|
|
|
private readonly ConcurrentDictionary<(string Path, string Agent), Mark> _reads = new();
|
|
private readonly ConcurrentDictionary<string, Mark> _writers = new();
|
|
private readonly SqliteStorage? _storage;
|
|
private readonly TimeProvider _clock;
|
|
|
|
public FileLedger(SqliteStorage? storage = null, TimeProvider? clock = null)
|
|
{
|
|
_storage = storage;
|
|
_clock = clock ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <summary>The SHA-256 of a file's bytes; <c>null</c> if it does not exist.</summary>
|
|
public static string? HashOf(string path)
|
|
{
|
|
if (!File.Exists(path))
|
|
return null;
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
return Convert.ToHexStringLower(SHA256.HashData(stream));
|
|
}
|
|
|
|
/// <summary>An agent read the file in this version.</summary>
|
|
public void NoteRead(string agentId, string path, string? hash)
|
|
{
|
|
if (hash is null)
|
|
return;
|
|
_reads[(Key(path), Agent(agentId))] = new Mark(hash, null, Now, agentId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether an agent may replace the file, given what it is now. <c>null</c>: yes. Pure
|
|
/// apart from the lookups — the caller holds the file's lock while it checks and writes.
|
|
/// </summary>
|
|
public FileConflict? CheckReplace(string agentId, string? runId, string path, string workspace, string? currentHash)
|
|
{
|
|
if (currentHash is null)
|
|
return null; // a new file replaces nothing
|
|
|
|
var key = Key(path);
|
|
_writers.TryGetValue(key, out var writer);
|
|
// The writer we know of is the one who made the file as it is now — otherwise
|
|
// someone we do not know of (a human, a tool without the ledger) changed it since.
|
|
var knownWriter = writer is not null && writer.Hash == currentHash ? writer : null;
|
|
|
|
if (_reads.TryGetValue((key, Agent(agentId)), out var read))
|
|
{
|
|
return read.Hash == currentHash
|
|
? null
|
|
: Conflict(FileConflict.ChangedSinceRead);
|
|
}
|
|
|
|
return knownWriter is not null && !SameAgent(knownWriter.AgentId, agentId)
|
|
? Conflict(FileConflict.NotRead)
|
|
: null;
|
|
|
|
FileConflict Conflict(string kind) => new(
|
|
path, workspace, agentId, runId,
|
|
knownWriter is not null && !SameAgent(knownWriter.AgentId, agentId) ? knownWriter.AgentId : null,
|
|
knownWriter is not null && !SameAgent(knownWriter.AgentId, agentId) ? knownWriter.RunId : null,
|
|
kind, Now);
|
|
}
|
|
|
|
/// <summary>The agent wrote the whole file: it is the last writer and knows what it wrote.</summary>
|
|
public void NoteReplace(string agentId, string? runId, string path, string? newHash)
|
|
{
|
|
if (newHash is null)
|
|
return;
|
|
var key = Key(path);
|
|
var mark = new Mark(newHash, runId, Now, agentId);
|
|
_writers[key] = mark;
|
|
_reads[(key, Agent(agentId))] = mark;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The agent appended. It is the last writer; its own read stays valid only if it knew
|
|
/// the file as it was before — everyone else's read is now of an older version.
|
|
/// </summary>
|
|
public void NoteAppend(string agentId, string? runId, string path, string? hashBefore, string? hashAfter)
|
|
{
|
|
if (hashAfter is null)
|
|
return;
|
|
var key = Key(path);
|
|
var mark = new Mark(hashAfter, runId, Now, agentId);
|
|
_writers[key] = mark;
|
|
var own = (key, Agent(agentId));
|
|
if (hashBefore is null || (_reads.TryGetValue(own, out var read) && read.Hash == hashBefore))
|
|
_reads[own] = mark;
|
|
}
|
|
|
|
/// <summary>Keeps the conflict for the analysis. Best effort — the refusal itself does not depend on it.</summary>
|
|
public async Task RecordAsync(FileConflict conflict, CancellationToken ct)
|
|
{
|
|
if (_storage is null)
|
|
return;
|
|
try
|
|
{
|
|
await _storage.WriteAsync(async conn =>
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
INSERT INTO FileConflicts (Path, Workspace, AgentId, RunId, OtherAgentId, OtherRunId, Kind, OccurredAt)
|
|
VALUES (@path, @workspace, @agent, @run, @other, @otherRun, @kind, @at)
|
|
""";
|
|
cmd.Parameters.AddWithValue("@path", conflict.Path);
|
|
cmd.Parameters.AddWithValue("@workspace", conflict.Workspace);
|
|
cmd.Parameters.AddWithValue("@agent", conflict.AgentId);
|
|
cmd.Parameters.AddWithValue("@run", (object?)conflict.RunId ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@other", (object?)conflict.OtherAgentId ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@otherRun", (object?)conflict.OtherRunId ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@kind", conflict.Kind);
|
|
cmd.Parameters.AddWithValue("@at", conflict.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture));
|
|
await cmd.ExecuteNonQueryAsync(ct);
|
|
}, ct);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
// The conflict was refused all the same; only the count is missing one.
|
|
}
|
|
}
|
|
|
|
/// <summary>The files with at least <paramref name="minimum"/> conflicts since <paramref name="sinceUtc"/>, most first.</summary>
|
|
public async Task<IReadOnlyList<FileConflictHotspot>> HotspotsAsync(DateTime sinceUtc, int minimum, int limit, CancellationToken ct)
|
|
{
|
|
if (_storage is null)
|
|
return [];
|
|
|
|
await using var conn = await _storage.OpenConnectionAsync(ct);
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
SELECT Path, Workspace, AgentId, OtherAgentId, OccurredAt
|
|
FROM FileConflicts
|
|
WHERE OccurredAt >= @since
|
|
""";
|
|
cmd.Parameters.AddWithValue("@since", sinceUtc.ToString("O", CultureInfo.InvariantCulture));
|
|
|
|
var rows = new List<(string Path, string Workspace, string Agent, string? Other, DateTime At)>();
|
|
await using (var reader = await cmd.ExecuteReaderAsync(ct))
|
|
{
|
|
while (await reader.ReadAsync(ct))
|
|
{
|
|
rows.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2),
|
|
reader.IsDBNull(3) ? null : reader.GetString(3),
|
|
DateTime.Parse(reader.GetString(4), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)));
|
|
}
|
|
}
|
|
|
|
return rows
|
|
.GroupBy(r => (r.Path, r.Workspace))
|
|
.Select(g => new FileConflictHotspot(
|
|
g.Key.Path, g.Key.Workspace, g.Count(), g.Max(r => r.At),
|
|
g.SelectMany(r => r.Other is null ? new[] { r.Agent } : new[] { r.Agent, r.Other })
|
|
.Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).ToList()))
|
|
.Where(h => h.Conflicts >= minimum)
|
|
.OrderByDescending(h => h.Conflicts).ThenByDescending(h => h.LastAtUtc)
|
|
.Take(limit)
|
|
.ToList();
|
|
}
|
|
|
|
private DateTime Now => _clock.GetUtcNow().UtcDateTime;
|
|
|
|
private static bool SameAgent(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static string Agent(string agentId) => agentId.ToLowerInvariant();
|
|
|
|
/// <summary>One key per file, however the path was written; case-insensitive where the file system is.</summary>
|
|
private static string Key(string path)
|
|
{
|
|
var full = Path.GetFullPath(path);
|
|
return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? full.ToLowerInvariant() : full;
|
|
}
|
|
}
|