Files
nexus/backend/Services/OpenClawOperationAuditStore.cs
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

351 lines
12 KiB
C#

using System.Text.Json;
using Microsoft.Extensions.Options;
namespace Nexus.Api.Services;
public sealed record OpenClawOperationDescriptor(
string Method,
string TargetType,
string TargetId,
string IntentFingerprint);
public enum OpenClawOperationClaimDisposition
{
Started,
Replayed,
InDoubt,
Conflict
}
public sealed record OpenClawOperationClaim(
OpenClawOperationClaimDisposition Disposition,
bool? PreviousOk = null,
string? PreviousState = null,
string? PreviousMessage = null);
public interface IOpenClawOperationAuditStore
{
string AuditPath { get; }
Task<OpenClawOperationClaim> ClaimAsync(
OpenClawInvocationContext context,
OpenClawOperationDescriptor operation,
CancellationToken cancellationToken = default);
Task CompleteAsync(
OpenClawInvocationContext context,
OpenClawOperationDescriptor operation,
bool ok,
string state,
string message,
string? errorCode = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Append-only, metadata-only mutation ledger. It deliberately stores no
/// Gateway credentials, prompts, command arguments, or raw Gateway results.
/// Hashed idempotency keys and intent fingerprints provide restart-safe
/// duplicate detection. A started operation without a terminal record is
/// treated as in-doubt and is never replayed automatically.
/// </summary>
public sealed class OpenClawOperationAuditStore : IOpenClawOperationAuditStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly string _auditPath;
private readonly Dictionary<string, OperationState> _operations = new(StringComparer.Ordinal);
private bool _loaded;
public OpenClawOperationAuditStore(IOptions<GatewayConnectorOptions> options)
{
_auditPath = ResolveAuditPath(
options.Value.OperationAuditPath,
options.Value.DeviceStatePath);
}
public string AuditPath => _auditPath;
public async Task<OpenClawOperationClaim> ClaimAsync(
OpenClawInvocationContext context,
OpenClawOperationDescriptor operation,
CancellationToken cancellationToken = default)
{
Validate(operation);
await _gate.WaitAsync(cancellationToken);
try
{
await EnsureLoadedAsync(cancellationToken);
var keyHash = OpenClawInvocationContextFactory.Hash(context.IdempotencyKey);
if (_operations.TryGetValue(keyHash, out var existing))
{
if (!string.Equals(
existing.IntentFingerprint,
operation.IntentFingerprint,
StringComparison.Ordinal))
{
return new OpenClawOperationClaim(
OpenClawOperationClaimDisposition.Conflict,
PreviousMessage: "Der Idempotency-Key wurde bereits für eine andere Aktion verwendet.");
}
if (!existing.Completed)
{
return new OpenClawOperationClaim(
OpenClawOperationClaimDisposition.InDoubt,
PreviousMessage: "Die frühere Aktion ist ohne bestätigtes Ergebnis protokolliert.");
}
return new OpenClawOperationClaim(
OpenClawOperationClaimDisposition.Replayed,
existing.Ok,
existing.State,
existing.Message);
}
var startedAt = DateTimeOffset.UtcNow;
var started = AuditRecord.Started(
context,
operation,
keyHash,
startedAt);
await AppendAsync(started, cancellationToken);
_operations[keyHash] = new OperationState(
operation.IntentFingerprint,
Completed: false,
Ok: null,
State: "started",
Message: "OpenClaw-Aktion wurde gestartet.");
return new OpenClawOperationClaim(OpenClawOperationClaimDisposition.Started);
}
finally
{
_gate.Release();
}
}
public async Task CompleteAsync(
OpenClawInvocationContext context,
OpenClawOperationDescriptor operation,
bool ok,
string state,
string message,
string? errorCode = null,
CancellationToken cancellationToken = default)
{
Validate(operation);
await _gate.WaitAsync(cancellationToken);
try
{
await EnsureLoadedAsync(cancellationToken);
var keyHash = OpenClawInvocationContextFactory.Hash(context.IdempotencyKey);
if (!_operations.TryGetValue(keyHash, out var existing) ||
!string.Equals(existing.IntentFingerprint, operation.IntentFingerprint, StringComparison.Ordinal))
{
throw new InvalidOperationException("OpenClaw operation must be claimed before it is completed.");
}
var completed = AuditRecord.Completed(
context,
operation,
keyHash,
ok,
state,
message,
errorCode,
DateTimeOffset.UtcNow);
await AppendAsync(completed, cancellationToken);
_operations[keyHash] = new OperationState(
operation.IntentFingerprint,
Completed: true,
Ok: ok,
State: state,
Message: message);
}
finally
{
_gate.Release();
}
}
public static string ResolveAuditPath(
string? configuredAuditPath,
string? configuredDeviceStatePath)
{
if (!string.IsNullOrWhiteSpace(configuredAuditPath))
return Path.GetFullPath(configuredAuditPath.Trim());
var devicePath = OpenClawDeviceIdentityStore.ResolveStatePath(configuredDeviceStatePath);
return Path.Combine(
Path.GetDirectoryName(devicePath)
?? throw new InvalidOperationException("OpenClaw state path has no parent directory."),
"operation-audit.jsonl");
}
private async Task EnsureLoadedAsync(CancellationToken cancellationToken)
{
if (_loaded)
return;
if (!File.Exists(_auditPath))
{
_loaded = true;
return;
}
OpenClawDeviceIdentityStore.EnsureRegularFile(_auditPath);
OpenClawDeviceIdentityStore.EnsureRestrictedPermissions(_auditPath, isDirectory: false);
using var stream = new FileStream(
_auditPath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite,
4096,
FileOptions.SequentialScan);
using var reader = new StreamReader(stream);
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
if (string.IsNullOrWhiteSpace(line))
continue;
AuditRecord record;
try
{
record = JsonSerializer.Deserialize<AuditRecord>(line, JsonOptions)
?? throw new JsonException("Audit record is empty.");
}
catch (JsonException exception)
{
throw new InvalidDataException(
$"OpenClaw operation audit at '{_auditPath}' is corrupt; refusing to risk a duplicate mutation.",
exception);
}
if (string.IsNullOrWhiteSpace(record.IdempotencyKeyHash) ||
string.IsNullOrWhiteSpace(record.IntentFingerprint))
{
throw new InvalidDataException(
$"OpenClaw operation audit at '{_auditPath}' contains an invalid record.");
}
_operations[record.IdempotencyKeyHash] = new OperationState(
record.IntentFingerprint,
Completed: string.Equals(record.Event, "completed", StringComparison.Ordinal),
record.Ok,
record.State,
record.Message);
}
_loaded = true;
}
private async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken)
{
var directory = Path.GetDirectoryName(_auditPath)
?? throw new InvalidOperationException("OpenClaw audit path has no parent directory.");
Directory.CreateDirectory(directory);
OpenClawDeviceIdentityStore.EnsureRestrictedPermissions(directory, isDirectory: true);
var serialized = JsonSerializer.Serialize(record, JsonOptions) + Environment.NewLine;
var bytes = System.Text.Encoding.UTF8.GetBytes(serialized);
await using var stream = new FileStream(
_auditPath,
FileMode.Append,
FileAccess.Write,
FileShare.Read,
4096,
FileOptions.WriteThrough);
await stream.WriteAsync(bytes, cancellationToken);
await stream.FlushAsync(cancellationToken);
OpenClawDeviceIdentityStore.EnsureRestrictedPermissions(_auditPath, isDirectory: false);
}
private static void Validate(OpenClawOperationDescriptor operation)
{
if (string.IsNullOrWhiteSpace(operation.Method) ||
string.IsNullOrWhiteSpace(operation.TargetType) ||
string.IsNullOrWhiteSpace(operation.TargetId) ||
string.IsNullOrWhiteSpace(operation.IntentFingerprint))
{
throw new ArgumentException("OpenClaw operation audit metadata is incomplete.", nameof(operation));
}
}
private sealed record OperationState(
string IntentFingerprint,
bool Completed,
bool? Ok,
string? State,
string? Message);
private sealed record AuditRecord(
int SchemaVersion,
string Event,
DateTimeOffset OccurredAt,
string OperationId,
string Method,
string TargetType,
string TargetId,
string Actor,
string CorrelationId,
string TraceParent,
string IdempotencyKeyHash,
string IntentFingerprint,
bool? Ok,
string? State,
string? Message,
string? ErrorCode)
{
public static AuditRecord Started(
OpenClawInvocationContext context,
OpenClawOperationDescriptor operation,
string keyHash,
DateTimeOffset occurredAt)
=> new(
1,
"started",
occurredAt,
context.CorrelationId,
operation.Method,
operation.TargetType,
operation.TargetId,
context.Actor,
context.CorrelationId,
context.TraceParent,
keyHash,
operation.IntentFingerprint,
null,
"started",
"OpenClaw-Aktion wurde gestartet.",
null);
public static AuditRecord Completed(
OpenClawInvocationContext context,
OpenClawOperationDescriptor operation,
string keyHash,
bool ok,
string state,
string message,
string? errorCode,
DateTimeOffset occurredAt)
=> new(
1,
"completed",
occurredAt,
context.CorrelationId,
operation.Method,
operation.TargetType,
operation.TargetId,
context.Actor,
context.CorrelationId,
context.TraceParent,
keyHash,
operation.IntentFingerprint,
ok,
state,
message,
errorCode);
}
}