Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
722 lines
32 KiB
C#
722 lines
32 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace ClawdDotNet.Core.Storage;
|
|
|
|
/// <summary>
|
|
/// Central place for access to the instance database.
|
|
///
|
|
/// The former version opened a connection per call without further settings —
|
|
/// without WAL and without waiting on locks. As soon as several agents write
|
|
/// at the same time, SQLite answers that with "database is locked". That looked like a limit
|
|
/// of SQLite, but was only missing configuration.
|
|
///
|
|
/// - WAL allows any number of readers in parallel with one writer.
|
|
/// - busy_timeout lets a writer wait briefly instead of failing immediately.
|
|
/// - Connection pooling avoids building a connection per call.
|
|
/// </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 creates the database with the default mask (0644 on the test VM),
|
|
// and the WAL and shared-memory files inherit its rights. They contain chat histories,
|
|
// memory and the arguments of every tool call. Also for existing files
|
|
// that date from before this line.
|
|
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>
|
|
/// Serializes writes within the process. SQLite only allows one writer anyway
|
|
/// — the queue here is more reliable than rejection by the
|
|
/// database and makes failure patterns reproducible.
|
|
/// </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();
|
|
|
|
AddMissingColumns(connection);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Columns added after the first version of a table.
|
|
///
|
|
/// <para><c>CREATE TABLE IF NOT EXISTS</c> alone is not enough for that: with an
|
|
/// existing database the statement does nothing at all, the new column is missing, and
|
|
/// only the next <c>INSERT</c> that names it fails — in operation, at the
|
|
/// user's, not at the developer's with the fresh file.</para>
|
|
/// </summary>
|
|
private static void AddMissingColumns(SqliteConnection connection)
|
|
{
|
|
// #554: Pairs the "started" entry with its completion.
|
|
AddColumnIfMissing(connection, "AuditLog", "CallId", "TEXT NOT NULL DEFAULT ''");
|
|
|
|
// #716: What happened when the budget refused a run.
|
|
AddColumnIfMissing(connection, "Tasks", "BudgetMissCount", "INTEGER NOT NULL DEFAULT 0");
|
|
AddColumnIfMissing(connection, "Tasks", "LastBudgetMissAt", "TEXT NULL");
|
|
AddColumnIfMissing(connection, "Tasks", "DeferredUntil", "TEXT NULL");
|
|
AddColumnIfMissing(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.
|
|
AddColumnIfMissing(connection, "InboundTriggers", "Slug", "TEXT NOT NULL DEFAULT ''");
|
|
AddColumnIfMissing(connection, "InboundTriggers", "Instruction", "TEXT NOT NULL DEFAULT ''");
|
|
AddColumnIfMissing(connection, "InboundTriggers", "Priority", "INTEGER NOT NULL DEFAULT 3");
|
|
AddColumnIfMissing(connection, "InboundTriggers", "Fields", "TEXT NOT NULL DEFAULT '[]'");
|
|
AddColumnIfMissing(connection, "InboundTriggers", "Strict", "INTEGER NOT NULL DEFAULT 1");
|
|
|
|
// #684: Who created a task — decides its board when nobody in particular is assigned.
|
|
AddColumnIfMissing(connection, "Tasks", "CreatedBy", "TEXT NOT NULL DEFAULT ''");
|
|
|
|
// #714: The task a task serves — the goal chain.
|
|
AddColumnIfMissing(connection, "Tasks", "ParentId", "TEXT NOT NULL DEFAULT ''");
|
|
|
|
// #697: The run a delegated run came from.
|
|
AddColumnIfMissing(connection, "RunReceipts", "CallerRunId", "TEXT NULL");
|
|
|
|
// #697: Where a task came from — the run, and the task that run worked on.
|
|
AddColumnIfMissing(connection, "Tasks", "OriginRunId", "TEXT NOT NULL DEFAULT ''");
|
|
AddColumnIfMissing(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.
|
|
AddColumnIfMissing(connection, "Tasks", "FillBy", "TEXT NULL");
|
|
AddColumnIfMissing(connection, "Tasks", "EstimateUsd", "TEXT NULL");
|
|
AddColumnIfMissing(connection, "Tasks", "FillAlertedAt", "TEXT NULL");
|
|
|
|
// #717: A large task got its planning run — split or special budget, the agent proposed.
|
|
AddColumnIfMissing(connection, "Tasks", "LargePlannedAt", "TEXT NULL");
|
|
|
|
// #717: Runs that drew on a special budget — outside the daily limits, visible apart.
|
|
AddColumnIfMissing(connection, "RunUsage", "BudgetPot", "TEXT NOT NULL DEFAULT ''");
|
|
// #686: who takes part in a meeting, in the order they speak.
|
|
AddColumnIfMissing(connection, "Tasks", "Participants", "TEXT NOT NULL DEFAULT ''");
|
|
// #712: the runtime, and whether a subscription paid — 0 $ is not "free".
|
|
AddColumnIfMissing(connection, "RunUsage", "Runtime", "TEXT NOT NULL DEFAULT ''");
|
|
AddColumnIfMissing(connection, "RunUsage", "OnSubscription", "INTEGER NOT NULL DEFAULT 0");
|
|
AddColumnIfMissing(connection, "RunReceipts", "BudgetPot", "TEXT NULL");
|
|
// #687: a lead that may decide a staged call instead of the human, until when, and its inbox task.
|
|
AddColumnIfMissing(connection, "StagedCalls", "Approver", "TEXT NULL");
|
|
AddColumnIfMissing(connection, "StagedCalls", "ApproverUntil", "TEXT NULL");
|
|
AddColumnIfMissing(connection, "StagedCalls", "ApproverTaskId", "TEXT NULL");
|
|
|
|
// Finds open calls at startup without reading the whole table.
|
|
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>
|
|
/// Adds a column if it is missing. If it is there, nothing happens.
|
|
///
|
|
/// <para>SQLite knows no <c>ADD COLUMN IF NOT EXISTS</c>, that's why we look
|
|
/// first. The default is mandatory: a <c>NOT NULL</c> column without a
|
|
/// default value cannot be added to a filled table.</para>
|
|
/// </summary>
|
|
private static void AddColumnIfMissing(SqliteConnection connection, string tabelle, string column, string definition)
|
|
{
|
|
using (var check = connection.CreateCommand())
|
|
{
|
|
check.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{tabelle}') WHERE name = @spalte";
|
|
check.Parameters.AddWithValue("@spalte", column);
|
|
|
|
if (Convert.ToInt32(check.ExecuteScalar()) > 0)
|
|
return;
|
|
}
|
|
|
|
using var toAdd = connection.CreateCommand();
|
|
toAdd.CommandText = $"ALTER TABLE {tabelle} ADD COLUMN {column} {definition}";
|
|
toAdd.ExecuteNonQuery();
|
|
}
|
|
}
|