Das Token sagt, wer schickt - nicht, wer den Inhalt geschrieben hat. Felder
mit festem Typ (Kennung, Zahl, Ganzzahl, Datum, Datum+Uhrzeit, ja/nein,
Auswahl, Mailadresse) koennen keine Anweisung tragen und gehen als geprueft
an den Agenten, auch als {feld} im Auftrag. Freitext bleibt Fremdinhalt.
Streng (Standard): unpassende Lieferung -> 400 mit Grund, im Audit.
Nachsichtig (fuer Absender, die sich nicht an die Felder halten): nichts
wird abgewiesen, Passendes gilt als geprueft, der Rest bleibt Fremdinhalt;
der Trigger ist dann 'unsicher' und wird so angezeigt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
682 lines
30 KiB
C#
682 lines
30 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).
|
|
-- #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();
|
|
}
|
|
}
|