351 lines
12 KiB
C#
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);
|
|
}
|
|
}
|