Files
ClawdDotNet/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
T
RichardandClaude Opus 5.5 e42ec100b0 Taskboard fasst nach: stockende, entblockte und wartende Aufgaben erkennen und nachfassen (#646)
Alle 30 Minuten, als Abfrage statt Modelllauf: Agentenaufgabe in Arbeit ohne
Aenderung (Vorgabe 24 h) -> einmal neu gestartet, stockt sie binnen einer
Woche wieder, geht es an die Leitung bzw. den Menschen; blockiert mit
erledigten Blockern -> wieder offen; @human-Aufgabe ueber 48 h -> in einer
Sammelaufgabe fuer den Menschen; in_review ueber 48 h und wiederholtes
LoopLimitExceeded (3 in 7 Tagen) -> Aufgabe an die Leitung. Einmal je Zustand,
hoechstens 10 Nachfassungen je Durchgang, jede im Audit. Befunde liegen
abfragbar in TaskFindings (fuer #680); WebUI-Karte 'Liegengeblieben'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:49:04 +02:00

722 lines
32 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);
-- #700: one day of one agent, condensed; stays when the rows move to the archive.
CREATE TABLE IF NOT EXISTS RunDaily (
Day TEXT NOT NULL,
AgentId TEXT NOT NULL,
UnitId TEXT NOT NULL DEFAULT '',
Runs INTEGER NOT NULL DEFAULT 0,
Failed INTEGER NOT NULL DEFAULT 0,
LoopLimit INTEGER NOT NULL DEFAULT 0,
PromptTokens INTEGER NOT NULL DEFAULT 0,
CompletionTokens INTEGER NOT NULL DEFAULT 0,
CostUsd TEXT NOT NULL DEFAULT '0',
ToolCalls INTEGER NOT NULL DEFAULT 0,
ToolErrors INTEGER NOT NULL DEFAULT 0,
Denied INTEGER NOT NULL DEFAULT 0,
Staged INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (Day, AgentId)
);
CREATE INDEX IF NOT EXISTS IX_RunDaily_Unit ON RunDaily (UnitId, Day);
-- #646: work that lives but does not move — once per kind, task and state it was found in.
CREATE TABLE IF NOT EXISTS TaskFindings (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Kind TEXT NOT NULL,
TaskId TEXT NOT NULL,
Mark TEXT NOT NULL,
Assignee TEXT NOT NULL DEFAULT '',
Title TEXT NOT NULL DEFAULT '',
Detail TEXT NOT NULL DEFAULT '',
SinceAt TEXT NOT NULL,
FirstSeenAt TEXT NOT NULL,
LastSeenAt TEXT NOT NULL,
ActedAt TEXT NULL,
Action TEXT NOT NULL DEFAULT '',
ResolvedAt TEXT NULL,
UNIQUE (Kind, TaskId, Mark)
);
CREATE INDEX IF NOT EXISTS IX_TaskFindings_Open ON TaskFindings (ResolvedAt, ActedAt);
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
-- #698: a unit's doctrine, every version kept — who, when, which run, why.
CREATE TABLE IF NOT EXISTS Doctrines (
UnitId TEXT NOT NULL,
Version INTEGER NOT NULL,
Text TEXT NOT NULL,
SetBy TEXT NOT NULL,
SetAt TEXT NOT NULL,
RunId TEXT NULL,
Reason TEXT NOT NULL DEFAULT '',
PRIMARY KEY (UnitId, Version)
);
-- #699: cost alerts, once per period (day, or month for a threshold), scope and kind.
CREATE TABLE IF NOT EXISTS CostAlerts (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Period TEXT NOT NULL,
Scope TEXT NOT NULL,
Kind TEXT NOT NULL,
Message TEXT NOT NULL,
RaisedAt TEXT NOT NULL,
UNIQUE (Period, Scope, Kind)
);
-- #693: runs picked for a lead to look at, and the measures a lead proposed.
CREATE TABLE IF NOT EXISTS SpotChecks (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
UnitId TEXT NOT NULL,
Lead TEXT NOT NULL,
AgentId TEXT NOT NULL,
RunId TEXT NOT NULL UNIQUE,
Why TEXT NOT NULL,
Findings TEXT NOT NULL,
CreatedAt TEXT NOT NULL,
Verdict TEXT NULL,
Note TEXT NULL,
ReviewedAt TEXT NULL,
TaskId TEXT NULL
);
CREATE INDEX IF NOT EXISTS IX_SpotChecks_Lead ON SpotChecks (Lead, Verdict);
CREATE INDEX IF NOT EXISTS IX_SpotChecks_Unit ON SpotChecks (UnitId, CreatedAt);
CREATE TABLE IF NOT EXISTS SpotMeasures (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
CheckId INTEGER NULL,
UnitId TEXT NOT NULL,
ProposedBy TEXT NOT NULL,
AgentId TEXT NOT NULL,
Kind TEXT NOT NULL,
Reasoning TEXT NOT NULL,
ProposedAt TEXT NOT NULL,
Status TEXT NOT NULL,
DecidedBy TEXT NULL,
DecidedAt TEXT NULL,
Note TEXT NULL,
TaskId TEXT NULL
);
-- #702: blind votes on staged calls, one ballot per voter with reasoning, evidence and run.
CREATE TABLE IF NOT EXISTS Votes (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
CallId INTEGER NOT NULL,
UnitId TEXT NOT NULL,
Proposer TEXT NOT NULL,
Operation TEXT NOT NULL,
ApproveAt INTEGER NOT NULL,
RejectAt INTEGER NOT NULL,
Quorum INTEGER NOT NULL,
Until TEXT NOT NULL,
CreatedAt TEXT NOT NULL,
Outcome TEXT NULL,
DecidedAt TEXT NULL
);
CREATE INDEX IF NOT EXISTS IX_Votes_Call ON Votes (CallId);
CREATE INDEX IF NOT EXISTS IX_Votes_Open ON Votes (Outcome);
CREATE TABLE IF NOT EXISTS VoteBallots (
VoteId INTEGER NOT NULL,
Voter TEXT NOT NULL,
TaskId TEXT NULL,
Yes INTEGER NULL,
Reasoning TEXT NOT NULL DEFAULT '',
Evidence TEXT NOT NULL DEFAULT '',
RunId TEXT NULL,
CastAt TEXT NULL,
PRIMARY KEY (VoteId, Voter)
);
-- #725: inbound triggers (token kept as hash) and every delivery, accepted or not.
CREATE TABLE IF NOT EXISTS InboundTriggers (
Id TEXT PRIMARY KEY,
Label TEXT NOT NULL,
AgentId TEXT NOT NULL,
TokenHash TEXT NOT NULL UNIQUE,
PerHour INTEGER NOT NULL,
CreatedAt TEXT NOT NULL,
RevokedAt TEXT NULL
);
CREATE TABLE IF NOT EXISTS InboundTriggerEvents (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
TriggerId TEXT NOT NULL,
ReceivedAt TEXT NOT NULL,
Outcome TEXT NOT NULL,
Bytes INTEGER NOT NULL,
TaskId TEXT NULL
);
CREATE INDEX IF NOT EXISTS IX_InboundTriggerEvents_Trigger ON InboundTriggerEvents (TriggerId, ReceivedAt);
-- #726: workflows agents propose, the human decides; and the tasks already put to their agent.
CREATE TABLE IF NOT EXISTS AutomationProposals (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
AgentId TEXT NOT NULL,
Purpose TEXT NOT NULL,
Trigger TEXT NOT NULL,
TaskIds TEXT NOT NULL,
Services TEXT NOT NULL,
Risks TEXT NOT NULL,
TestGuide TEXT NOT NULL,
WorkflowJson TEXT NOT NULL,
Justification TEXT NULL,
Summary TEXT NOT NULL,
Nodes TEXT NOT NULL,
Warnings TEXT NOT NULL,
Savings TEXT NOT NULL,
Status TEXT NOT NULL,
Reason TEXT NULL,
CreatedAt TEXT NOT NULL,
DecidedAt TEXT NULL,
TaskId TEXT NULL,
Answers INTEGER NULL,
Argument TEXT NULL
);
CREATE TABLE IF NOT EXISTS AutomationCandidates (
TaskId TEXT NOT NULL,
NotedAt TEXT NOT NULL
);
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
);
-- #689: Escalations and conflicts, and the decisions that close them.
CREATE TABLE IF NOT EXISTS Cases (
Id TEXT PRIMARY KEY,
Kind TEXT NOT NULL,
Status TEXT NOT NULL,
Holder TEXT NOT NULL DEFAULT '',
OpenedUnitId TEXT NULL,
OpenedAt TEXT NOT NULL,
DeadlineUtc TEXT NOT NULL,
Json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS IX_Cases_Status
ON Cases (Status, OpenedAt);
CREATE TABLE IF NOT EXISTS Decisions (
Id TEXT PRIMARY KEY,
ScopeUnitId TEXT NULL,
DecidedAt TEXT NOT NULL,
Json TEXT NOT NULL
);
-- #719: The history of the workspaces. Append-only; only the retention removes lines,
-- never a file's newest. Path is relative to the Agents folder.
CREATE TABLE IF NOT EXISTS FileHistory (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Workspace TEXT NOT NULL,
Path TEXT NOT NULL,
Action TEXT NOT NULL,
AgentId TEXT NULL,
RunId TEXT NULL,
HashBefore TEXT NULL,
HashAfter TEXT NULL,
SizeAfter INTEGER NULL,
Stored INTEGER NOT NULL DEFAULT 0,
OccurredAt TEXT NOT NULL,
Note TEXT NULL
);
CREATE INDEX IF NOT EXISTS IX_FileHistory_Path ON FileHistory (Path, Id);
CREATE INDEX IF NOT EXISTS IX_FileHistory_Run ON FileHistory (RunId);
-- #719: What the history knows each file to be now — the reconciliation compares against it.
CREATE TABLE IF NOT EXISTS FileHistoryState (
Path TEXT PRIMARY KEY,
Workspace TEXT NOT NULL,
Hash TEXT NOT NULL,
Size INTEGER NOT NULL,
Modified INTEGER NOT NULL
);
-- #720: Writes refused because they would have lost someone's change.
CREATE TABLE IF NOT EXISTS FileConflicts (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Path TEXT NOT NULL,
Workspace TEXT NOT NULL,
AgentId TEXT NOT NULL,
RunId TEXT NULL,
OtherAgentId TEXT NULL,
OtherRunId TEXT NULL,
Kind TEXT NOT NULL,
OccurredAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS IX_FileConflicts_OccurredAt ON FileConflicts (OccurredAt);
-- #713: Personal tokens for the MCP server — only the hash is kept.
CREATE TABLE IF NOT EXISTS McpTokens (
Id TEXT PRIMARY KEY,
Hash TEXT NOT NULL UNIQUE,
AgentId TEXT NOT NULL,
Label TEXT NOT NULL DEFAULT '',
CreatedAt TEXT NOT NULL,
ExpiresAt TEXT NOT NULL,
RevokedAt TEXT NULL
);
-- #717: Special budgets — one pot per task tree, approved by a human.
CREATE TABLE IF NOT EXISTS SpecialBudgets (
TaskId TEXT PRIMARY KEY COLLATE NOCASE,
AmountUsd TEXT NOT NULL,
Reason TEXT NOT NULL DEFAULT '',
RequestedBy TEXT NOT NULL DEFAULT '',
ApprovedAt 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");
// #725: a trigger's address name, the human's instruction for every delivery, and its tasks' priority.
SpalteErgaenzen(connection, "InboundTriggers", "Slug", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "InboundTriggers", "Instruction", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "InboundTriggers", "Priority", "INTEGER NOT NULL DEFAULT 3");
SpalteErgaenzen(connection, "InboundTriggers", "Fields", "TEXT NOT NULL DEFAULT '[]'");
SpalteErgaenzen(connection, "InboundTriggers", "Strict", "INTEGER NOT NULL DEFAULT 1");
// #684: Who created a task — decides its board when nobody in particular is assigned.
SpalteErgaenzen(connection, "Tasks", "CreatedBy", "TEXT NOT NULL DEFAULT ''");
// #714: The task a task serves — the goal chain.
SpalteErgaenzen(connection, "Tasks", "ParentId", "TEXT NOT NULL DEFAULT ''");
// #697: The run a delegated run came from.
SpalteErgaenzen(connection, "RunReceipts", "CallerRunId", "TEXT NULL");
// #697: Where a task came from — the run, and the task that run worked on.
SpalteErgaenzen(connection, "Tasks", "OriginRunId", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "Tasks", "OriginTaskId", "TEXT NOT NULL DEFAULT ''");
// #717: Fill work — a deadline instead of an appointment, the author's estimate, and
// when a human was asked because no room was found.
SpalteErgaenzen(connection, "Tasks", "FillBy", "TEXT NULL");
SpalteErgaenzen(connection, "Tasks", "EstimateUsd", "TEXT NULL");
SpalteErgaenzen(connection, "Tasks", "FillAlertedAt", "TEXT NULL");
// #717: A large task got its planning run — split or special budget, the agent proposed.
SpalteErgaenzen(connection, "Tasks", "LargePlannedAt", "TEXT NULL");
// #717: Runs that drew on a special budget — outside the daily limits, visible apart.
SpalteErgaenzen(connection, "RunUsage", "BudgetPot", "TEXT NOT NULL DEFAULT ''");
// #686: who takes part in a meeting, in the order they speak.
SpalteErgaenzen(connection, "Tasks", "Participants", "TEXT NOT NULL DEFAULT ''");
// #712: the runtime, and whether a subscription paid — 0 $ is not "free".
SpalteErgaenzen(connection, "RunUsage", "Runtime", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "RunUsage", "OnSubscription", "INTEGER NOT NULL DEFAULT 0");
SpalteErgaenzen(connection, "RunReceipts", "BudgetPot", "TEXT NULL");
// #687: a lead that may decide a staged call instead of the human, until when, and its inbox task.
SpalteErgaenzen(connection, "StagedCalls", "Approver", "TEXT NULL");
SpalteErgaenzen(connection, "StagedCalls", "ApproverUntil", "TEXT NULL");
SpalteErgaenzen(connection, "StagedCalls", "ApproverTaskId", "TEXT NULL");
// 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();
}
}