feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user