- TaskBoard: das Brett einer Aufgabe wird nicht gespeichert, es folgt aus der Organisation - Einheit des Zustaendigen, sonst des Anlegers. Offene Aufgaben ziehen damit beim Umzug mit. Sichtbar: das eigene Brett, fuer eine Leitung alle darunter, und was man selbst angelegt hat (nach oben Gereichtes bleibt im Blick). - TaskItem.CreatedBy, in der Datei als created_by und in der DB. Ein Import aus einer Datei ohne das Feld behaelt, was die DB weiss. - Taskboard-Tool: task_list, task_find und die Dublettenpruefung sehen nur die eigenen Bretter - eine aehnliche Buchhaltungsaufgabe haelt keine Marketingaufgabe auf und bekommt keinen Kommentar von dort. task_update und task_comment antworten auf eine fremde Aufgabe wie auf eine unbekannte. - TaskboardService.ListAsync mit Sichtfilter: erst filtern, dann kappen. - WebUI: jede Aufgabe nennt ihr Brett, ein Filter waehlt eines aus. - Konzept (Abschnitt 3) und Einrichtungshandbuch nachgetragen. Ohne Einheiten aendert sich nichts - ein Brett fuer alle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
418 lines
18 KiB
C#
418 lines
18 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace ClawdDotNet.Core.Storage;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class SqliteStorage
|
|
{
|
|
private readonly string _connectionString;
|
|
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
|
|
|
/// <summary>Every database opened in this process: full path → connection string.</summary>
|
|
private static readonly ConcurrentDictionary<string, string> OpenedDatabases = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
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();
|
|
OpenedDatabases[Path.GetFullPath(databasePath)] = _connectionString;
|
|
|
|
Initialize();
|
|
|
|
// #575: SQLite legt die Datenbank mit der Standardmaske an (auf der Test-VM 0644),
|
|
// und WAL- und Shared-Memory-Datei erben deren Rechte. Darin stehen Chatverläufe,
|
|
// Gedächtnis und die Argumente jedes Werkzeugaufrufs. Auch für bestehende Dateien,
|
|
// die noch aus der Zeit vor dieser Zeile stammen.
|
|
foreach (var datei in new[] { databasePath, databasePath + "-wal", databasePath + "-shm" })
|
|
{
|
|
if (File.Exists(datei))
|
|
AppPaths.RestrictToOwner(datei);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closes the pooled connections of the databases at <paramref name="path"/> — a
|
|
/// database file, or a directory and everything below it — so the files can be deleted
|
|
/// or replaced.
|
|
///
|
|
/// <para>Not <see cref="SqliteConnection.ClearAllPools"/>: that is process-wide and also
|
|
/// takes the connections of every other database in use. The tests used it to clean up
|
|
/// their temporary databases, and a test running in parallel then lost its connection
|
|
/// while opening it — an ObjectDisposedException in <c>ApplyPragmas</c>, one in every
|
|
/// few full runs, always in a different test.</para>
|
|
/// </summary>
|
|
public static void ReleasePooledConnections(string path)
|
|
{
|
|
var full = Path.GetFullPath(path);
|
|
var below = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
|
|
|
foreach (var (database, connectionString) in OpenedDatabases)
|
|
{
|
|
if (!string.Equals(database, full, StringComparison.OrdinalIgnoreCase)
|
|
&& !database.StartsWith(below, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
using var connection = new SqliteConnection(connectionString);
|
|
SqliteConnection.ClearPool(connection);
|
|
}
|
|
}
|
|
|
|
public SqliteConnection OpenConnection()
|
|
{
|
|
var connection = new SqliteConnection(_connectionString);
|
|
connection.Open();
|
|
ApplyPragmas(connection);
|
|
return connection;
|
|
}
|
|
|
|
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken ct)
|
|
{
|
|
var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync(ct);
|
|
ApplyPragmas(connection);
|
|
return connection;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<T> WriteAsync<T>(Func<SqliteConnection, Task<T>> 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<SqliteConnection, Task> action, CancellationToken ct)
|
|
=> await WriteAsync<object?>(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,
|
|
-- Paart den "angefangen"-Eintrag mit seinem Abschluss (#554).
|
|
-- Wird fuer bestehende Datenbanken in Nachruesten() ergaenzt.
|
|
CallId TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
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);
|
|
|
|
-- #641: "Gleichartiges kuenftig erlauben" - je Agent, Werkzeug und Operation.
|
|
-- Ein Widerruf setzt WiderrufenAm; geloescht wird nichts.
|
|
CREATE TABLE IF NOT EXISTS Dauerfreigaben (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
AgentId TEXT NOT NULL,
|
|
Tool TEXT NOT NULL,
|
|
Operation TEXT NOT NULL,
|
|
ErteiltVon TEXT NOT NULL,
|
|
ErteiltAm TEXT NOT NULL,
|
|
AnlassId INTEGER NULL,
|
|
WiderrufenAm TEXT NULL,
|
|
WiderrufenVon TEXT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_Dauerfreigaben_Agent
|
|
ON Dauerfreigaben (AgentId, Tool, Operation);
|
|
|
|
-- #660: Prompt history - prompt and response of every run, kept as long as the
|
|
-- retention of the agent allows. System prompts are stored once per text.
|
|
CREATE TABLE IF NOT EXISTS RunTranscripts (
|
|
RunId TEXT PRIMARY KEY,
|
|
AgentId TEXT NOT NULL,
|
|
Model TEXT NOT NULL DEFAULT '',
|
|
Source TEXT NOT NULL DEFAULT '',
|
|
TaskId TEXT NULL,
|
|
StartedAt TEXT NOT NULL,
|
|
CompletedAt TEXT NULL,
|
|
Status TEXT NOT NULL DEFAULT '',
|
|
SystemPromptHash TEXT NULL,
|
|
Prompt TEXT NOT NULL DEFAULT '',
|
|
Response TEXT NOT NULL DEFAULT '',
|
|
EntriesJson TEXT NOT NULL DEFAULT '[]',
|
|
SizeChars INTEGER NOT NULL DEFAULT 0,
|
|
Truncated INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunTranscripts_Agent
|
|
ON RunTranscripts (AgentId, StartedAt);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunTranscripts_Started
|
|
ON RunTranscripts (StartedAt DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS SystemPrompts (
|
|
Hash TEXT PRIMARY KEY,
|
|
Text TEXT NOT NULL,
|
|
FirstSeenAt TEXT NOT NULL
|
|
);
|
|
""";
|
|
|
|
cmd.ExecuteNonQuery();
|
|
|
|
Nachruesten(connection);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spalten, die nach der ersten Fassung einer Tabelle dazugekommen sind.
|
|
///
|
|
/// <para><c>CREATE TABLE IF NOT EXISTS</c> allein genügt dafür nicht: Bei einer
|
|
/// bestehenden Datenbank tut die Anweisung gar nichts, die neue Spalte fehlt, und
|
|
/// erst das nächste <c>INSERT</c>, das sie benennt, scheitert — im Betrieb, beim
|
|
/// Anwender, nicht beim Entwickler mit der frischen Datei.</para>
|
|
/// </summary>
|
|
private static void Nachruesten(SqliteConnection connection)
|
|
{
|
|
// #554: Paart den "angefangen"-Eintrag mit seinem Abschluss.
|
|
SpalteErgaenzen(connection, "AuditLog", "CallId", "TEXT NOT NULL DEFAULT ''");
|
|
|
|
// #716: What happened when the budget refused a run.
|
|
SpalteErgaenzen(connection, "Tasks", "BudgetMissCount", "INTEGER NOT NULL DEFAULT 0");
|
|
SpalteErgaenzen(connection, "Tasks", "LastBudgetMissAt", "TEXT NULL");
|
|
SpalteErgaenzen(connection, "Tasks", "DeferredUntil", "TEXT NULL");
|
|
SpalteErgaenzen(connection, "Tasks", "CatchUpPending", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
// #684: Who created a task — decides its board when nobody in particular is assigned.
|
|
SpalteErgaenzen(connection, "Tasks", "CreatedBy", "TEXT NOT NULL DEFAULT ''");
|
|
|
|
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
|
|
using var index = connection.CreateCommand();
|
|
index.CommandText = """
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Call
|
|
ON AuditLog (CallId, Id);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Agent
|
|
ON AuditLog (AgentId, Id DESC);
|
|
""";
|
|
index.ExecuteNonQuery();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ergänzt eine Spalte, falls sie fehlt. Ist sie da, geschieht nichts.
|
|
///
|
|
/// <para>SQLite kennt kein <c>ADD COLUMN IF NOT EXISTS</c>, deshalb wird erst
|
|
/// nachgesehen. Die Vorgabe ist Pflicht: Eine <c>NOT NULL</c>-Spalte ohne
|
|
/// Vorgabewert lässt sich einer gefüllten Tabelle nicht hinzufügen.</para>
|
|
/// </summary>
|
|
private static void SpalteErgaenzen(SqliteConnection connection, string tabelle, string spalte, string definition)
|
|
{
|
|
using (var pruefen = connection.CreateCommand())
|
|
{
|
|
pruefen.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{tabelle}') WHERE name = @spalte";
|
|
pruefen.Parameters.AddWithValue("@spalte", spalte);
|
|
|
|
if (Convert.ToInt32(pruefen.ExecuteScalar()) > 0)
|
|
return;
|
|
}
|
|
|
|
using var ergaenzen = connection.CreateCommand();
|
|
ergaenzen.CommandText = $"ALTER TABLE {tabelle} ADD COLUMN {spalte} {definition}";
|
|
ergaenzen.ExecuteNonQuery();
|
|
}
|
|
}
|