using Microsoft.Data.Sqlite; namespace ClawdDotNet.Core.Storage; /// /// Zentrale Stelle für den Zugang zur Instanz-Datenbank. /// /// Die frühere Fassung öffnete pro Aufruf eine Verbindung ohne weitere Einstellungen — /// ohne WAL und ohne Wartezeit bei Sperren. Sobald mehrere Agenten gleichzeitig /// schreiben, quittiert SQLite das mit "database is locked". Das sah nach einer Grenze /// von SQLite aus, war aber nur fehlende Konfiguration. /// /// - WAL erlaubt beliebig viele Leser parallel zu einem Schreiber. /// - busy_timeout lässt einen Schreiber kurz warten, statt sofort zu scheitern. /// - Connection-Pooling vermeidet den Aufbau je Aufruf. /// public sealed class SqliteStorage { private readonly string _connectionString; private readonly SemaphoreSlim _writeGate = new(1, 1); public string DatabasePath { get; } public SqliteStorage(string databasePath) { DatabasePath = databasePath; var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath)); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); _connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, Mode = SqliteOpenMode.ReadWriteCreate, Cache = SqliteCacheMode.Shared, Pooling = true, DefaultTimeout = 30 }.ToString(); Initialize(); } public SqliteConnection OpenConnection() { var connection = new SqliteConnection(_connectionString); connection.Open(); ApplyPragmas(connection); return connection; } public async Task OpenConnectionAsync(CancellationToken ct) { var connection = new SqliteConnection(_connectionString); await connection.OpenAsync(ct); ApplyPragmas(connection); return connection; } /// /// Serialisiert Schreibvorgänge im Prozess. SQLite lässt ohnehin nur einen Schreiber /// zu — die Warteschlange hier ist verlässlicher als das Zurückweisen durch die /// Datenbank und macht Fehlerbilder reproduzierbar. /// public async Task WriteAsync(Func> action, CancellationToken ct) { await _writeGate.WaitAsync(ct); try { await using var connection = await OpenConnectionAsync(ct); return await action(connection); } finally { _writeGate.Release(); } } public async Task WriteAsync(Func action, CancellationToken ct) => await WriteAsync(async conn => { await action(conn); return null; }, ct); private static void ApplyPragmas(SqliteConnection connection) { using var cmd = connection.CreateCommand(); cmd.CommandText = """ PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON; """; cmd.ExecuteNonQuery(); } private void Initialize() { using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); cmd.CommandText = """ CREATE TABLE IF NOT EXISTS ToolState ( Key TEXT PRIMARY KEY, Value TEXT ); CREATE TABLE IF NOT EXISTS Memories ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Scope TEXT NOT NULL, OwnerId TEXT NOT NULL, Category TEXT NOT NULL, Subject TEXT NOT NULL, MemoryKey TEXT NULL, Content TEXT NOT NULL, Tags TEXT NOT NULL DEFAULT '', Importance INTEGER NOT NULL DEFAULT 3, CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL, CreatedBy TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS IX_Memories_Lookup ON Memories (Scope, OwnerId, Subject); CREATE INDEX IF NOT EXISTS IX_Memories_Recent ON Memories (Scope, OwnerId, UpdatedAt DESC); -- Ein Schluessel identifiziert eine Erinnerung eindeutig; erneutes Merken -- unter demselben Schluessel aktualisiert sie, statt eine zweite anzulegen. CREATE UNIQUE INDEX IF NOT EXISTS UX_Memories_Key ON Memories (Scope, OwnerId, MemoryKey) WHERE MemoryKey IS NOT NULL; CREATE TABLE IF NOT EXISTS RunUsage ( Id INTEGER PRIMARY KEY AUTOINCREMENT, AgentId TEXT NOT NULL, Model TEXT NOT NULL, PromptTokens INTEGER NOT NULL, CompletionTokens INTEGER NOT NULL, CachedTokens INTEGER NOT NULL DEFAULT 0, CostUsd TEXT NOT NULL DEFAULT '0', CostIsKnown INTEGER NOT NULL DEFAULT 0, Status TEXT NOT NULL DEFAULT '', StepCount INTEGER NOT NULL DEFAULT 0, DurationMs INTEGER NOT NULL DEFAULT 0, OccurredAt TEXT NOT NULL, -- Ortsdatum, damit ein Tagesbudget der Wahrnehmung des Benutzers folgt -- und die Abfrage ohne Zeitzonenrechnerei auskommt. UsageDate TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS IX_RunUsage_Day ON RunUsage (UsageDate, AgentId); CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent ON RunUsage (OccurredAt DESC); -- Taskboard (A1): Ausführungszustand der Aufgaben. Die Definition lebt in -- Markdown-Dateien; diese Tabelle spiegelt sie und macht das Claiming atomar. CREATE TABLE IF NOT EXISTS Tasks ( Id TEXT PRIMARY KEY, Title TEXT NOT NULL DEFAULT '', Status TEXT NOT NULL DEFAULT 'todo', Type TEXT NOT NULL DEFAULT 'work', Priority INTEGER NOT NULL DEFAULT 3, Assignee TEXT NOT NULL DEFAULT '@human', WhenKind TEXT NULL, WhenValue TEXT NULL, WhenTz TEXT NULL, RequireApproval INTEGER NOT NULL DEFAULT 0, Acceptance TEXT NOT NULL DEFAULT '', -- Blocker als "|a|b|"; die Begrenzer verhindern Teiltreffer bei der Suche. BlockedBy TEXT NOT NULL DEFAULT '', OnlyWhenMarketOpen INTEGER NOT NULL DEFAULT 0, Body TEXT NOT NULL DEFAULT '', FileName TEXT NOT NULL DEFAULT '', -- Nur für Typ tool_job: welches Tool mit welcher Job-Art getickt wird. ToolName TEXT NOT NULL DEFAULT '', JobTypeId TEXT NOT NULL DEFAULT '', -- Ausführungszustand: nur das Board schreibt hier. LastOccurrence TEXT NULL, ClaimToken TEXT NULL, ClaimedAt TEXT NULL, CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS IX_Tasks_Status ON Tasks (Status); CREATE INDEX IF NOT EXISTS IX_Tasks_Assignee ON Tasks (Assignee); -- Audit-Log (A3): ein Eintrag je Tool-Aufruf. Append-only — die Herkunft -- stempelt die Engine, Korrekturen sind neue Zeilen. CREATE TABLE IF NOT EXISTS AuditLog ( Id INTEGER PRIMARY KEY AUTOINCREMENT, RunId TEXT NOT NULL, AgentId TEXT NOT NULL, Model TEXT NOT NULL DEFAULT '', Source TEXT NOT NULL DEFAULT 'unknown', Tool TEXT NOT NULL, Arguments TEXT NOT NULL DEFAULT '', Status TEXT NOT NULL, Summary TEXT NOT NULL DEFAULT '', DurationMs INTEGER NOT NULL DEFAULT 0, OccurredAt TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS IX_AuditLog_Run ON AuditLog (RunId, Id); CREATE INDEX IF NOT EXISTS IX_AuditLog_Recent ON AuditLog (Id DESC); -- Abschluss-Belege (Receipts): ein Beleg je Lauf, verknüpft mit einem Task. CREATE TABLE IF NOT EXISTS RunReceipts ( Id INTEGER PRIMARY KEY AUTOINCREMENT, RunId TEXT NOT NULL, AgentId TEXT NOT NULL, Model TEXT NOT NULL DEFAULT '', Source TEXT NOT NULL DEFAULT 'unknown', TaskId TEXT NULL, Status TEXT NOT NULL DEFAULT '', StepCount INTEGER NOT NULL DEFAULT 0, PromptTokens INTEGER NOT NULL DEFAULT 0, CompletionTokens INTEGER NOT NULL DEFAULT 0, CachedTokens INTEGER NOT NULL DEFAULT 0, CostUsd TEXT NOT NULL DEFAULT '0', CostIsKnown INTEGER NOT NULL DEFAULT 0, DurationMs INTEGER NOT NULL DEFAULT 0, ResultRef TEXT NOT NULL DEFAULT '', OccurredAt TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS IX_RunReceipts_Task ON RunReceipts (TaskId); CREATE INDEX IF NOT EXISTS IX_RunReceipts_Run ON RunReceipts (RunId); -- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird -- genau der gespeicherte Argument-JSON (Plan-Freeze). CREATE TABLE IF NOT EXISTS StagedCalls ( Id INTEGER PRIMARY KEY AUTOINCREMENT, RunId TEXT NOT NULL, AgentId TEXT NOT NULL, InstanceId TEXT NOT NULL DEFAULT '', Tool TEXT NOT NULL, Action TEXT NULL, ArgumentsJson TEXT NOT NULL DEFAULT '', Proposal TEXT NOT NULL DEFAULT '', Status TEXT NOT NULL DEFAULT 'Pending', CreatedAt TEXT NOT NULL, DecidedAt TEXT NULL, DecidedBy TEXT NULL, ResultRef TEXT NULL, RejectionReason TEXT NULL ); CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending ON StagedCalls (Status, Id); """; cmd.ExecuteNonQuery(); } }