feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Wo die Anwendung ihre eigenen Dateien ablegt — getrennt nach Programm und Daten.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): Bisher lagen Einstellungen und Arbeitsordner neben
|
||||
/// der Programmdatei (<c>AppDomain.CurrentDomain.BaseDirectory</c>). Unter Windows in
|
||||
/// einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung in <c>/opt</c>
|
||||
/// oder <c>/usr/lib</c> und ist für den Dienstbenutzer <b>nicht schreibbar</b>.
|
||||
///
|
||||
/// Deshalb hier die übliche Trennung: Programm bleibt, wo es installiert ist, Daten
|
||||
/// wandern in das Verzeichnis des Benutzers (XDG unter Linux, <c>%APPDATA%</c> unter
|
||||
/// Windows). Beides lässt sich per Umgebungsvariable überschreiben — für Dienste, die
|
||||
/// nach <c>/var/lib</c> schreiben sollen, und für Tests.
|
||||
/// </summary>
|
||||
public static class AppPaths
|
||||
{
|
||||
private const string AppFolder = "ClawdDotNet";
|
||||
|
||||
/// <summary>Einstellungen und Schlüssel. Klein, selten geschrieben, gehört gesichert.</summary>
|
||||
public static string ConfigDirectory { get; } = ResolveConfig();
|
||||
|
||||
/// <summary>Instanzen, Datenbanken, Logs. Groß, oft geschrieben.</summary>
|
||||
public static string DataDirectory { get; } = ResolveData();
|
||||
|
||||
/// <summary>
|
||||
/// Das Verzeichnis der Programmdatei.
|
||||
///
|
||||
/// Weiterhin nötig, um bestehende Windows-Installationen zu finden: Dort liegt die
|
||||
/// <c>AppSettings.json</c> noch am alten Ort, und ein Update darf sie nicht
|
||||
/// verwaisen lassen.
|
||||
/// </summary>
|
||||
public static string ProgramDirectory => AppContext.BaseDirectory;
|
||||
|
||||
/// <summary>Legt das Verzeichnis an und schränkt unter Unix die Rechte auf den Benutzer ein.</summary>
|
||||
public static string EnsureDirectory(string path)
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
|
||||
// 0700: In den Konfigurationsverzeichnissen liegen Schlüssel und Zugangsdaten.
|
||||
// Unter Windows regelt das die Vererbung aus dem Benutzerprofil, unter Linux
|
||||
// wären es sonst je nach umask 0755 — für alle lesbar.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
}
|
||||
catch (IOException) { /* etwa auf Netzlaufwerken ohne Rechteverwaltung */ }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>Schränkt eine Datei unter Unix auf <c>0600</c> ein. Unter Windows wirkungslos.</summary>
|
||||
public static void RestrictToOwner(string filePath)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
// ─── Auflösung ───
|
||||
|
||||
private static string ResolveConfig()
|
||||
{
|
||||
if (FromEnvironment("CLAWD_CONFIG_DIR") is { } explicitDir)
|
||||
return explicitDir;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppFolder);
|
||||
|
||||
if (FromEnvironment("XDG_CONFIG_HOME") is { } xdg)
|
||||
return Path.Combine(xdg, AppFolder.ToLowerInvariant());
|
||||
|
||||
if (FromEnvironment("HOME") is { } home)
|
||||
return Path.Combine(home, ".config", AppFolder.ToLowerInvariant());
|
||||
|
||||
// Ein Dienst ohne HOME. Lieber ein fester, dokumentierter Ort als ein relativer
|
||||
// Pfad, der vom Arbeitsverzeichnis abhängt und beim nächsten Start woanders liegt.
|
||||
return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static string ResolveData()
|
||||
{
|
||||
if (FromEnvironment("CLAWD_DATA_DIR") is { } explicitDir)
|
||||
return explicitDir;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolder);
|
||||
|
||||
if (FromEnvironment("XDG_DATA_HOME") is { } xdg)
|
||||
return Path.Combine(xdg, AppFolder.ToLowerInvariant());
|
||||
|
||||
if (FromEnvironment("HOME") is { } home)
|
||||
return Path.Combine(home, ".local", "share", AppFolder.ToLowerInvariant());
|
||||
|
||||
return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static string? FromEnvironment(string name)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>GetFolderPath</c> kann eine leere Zeichenkette liefern (Dienstkonto ohne
|
||||
/// geladenes Profil). <c>Path.Combine("", …)</c> ergäbe dann einen relativen Pfad —
|
||||
/// die Datei landete im Arbeitsverzeichnis und wäre beim nächsten Start verschwunden.
|
||||
/// </summary>
|
||||
private static string Combine(string root, string folder)
|
||||
=> string.IsNullOrWhiteSpace(root)
|
||||
? Path.Combine(AppContext.BaseDirectory, folder)
|
||||
: Path.Combine(root, folder);
|
||||
}
|
||||
@@ -29,11 +29,15 @@ public static class AtomicFile
|
||||
/// einer gewinnt. Ohne Serialisierung scheitern sie aber zusätzlich: Windows lehnt
|
||||
/// zwei gleichzeitige Ersetzungen desselben Ziels mit "Zugriff verweigert" ab.
|
||||
/// Das Anstellen kostet nichts und macht das Ergebnis vorhersagbar.
|
||||
///
|
||||
/// Der Schlüssel kommt aus <see cref="PathBoundary.CanonicalKey"/>: Unter Windows
|
||||
/// meinen "Config.json" und "config.json" dieselbe Datei und brauchen dieselbe
|
||||
/// Sperre, unter Linux sind es zwei Dateien, die sich keine teilen dürfen.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks = new();
|
||||
|
||||
private static SemaphoreSlim LockFor(string fullPath)
|
||||
=> PathLocks.GetOrAdd(fullPath.ToLowerInvariant(), _ => new SemaphoreSlim(1, 1));
|
||||
=> PathLocks.GetOrAdd(PathBoundary.CanonicalKey(fullPath), _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht und begrenzt Dateipfade so, wie es das jeweilige Dateisystem tut.
|
||||
///
|
||||
/// Hintergrund: Die Einschließungsprüfungen im Projekt verglichen mit
|
||||
/// <c>OrdinalIgnoreCase</c> — die Annahme von Windows, dass Groß- und Kleinschreibung
|
||||
/// keine Rolle spielt. Unter Linux ist das falsch: <c>/home/x/Workspace</c> und
|
||||
/// <c>/home/x/workspace</c> sind zwei verschiedene Verzeichnisse. Ein Kandidat im
|
||||
/// zweiten würde als „innerhalb" des ersten durchgehen.
|
||||
///
|
||||
/// Betroffen waren drei Sandbox-Grenzen (FileRW, FTP) und die Archiventpackung.
|
||||
///
|
||||
/// Zweiter Punkt, den es unter Windows so nicht gab: <b>symbolische Verknüpfungen</b>.
|
||||
/// <c>Path.GetFullPath</c> löst sie nicht auf — es rechnet nur <c>..</c> heraus. Legt
|
||||
/// ein Agent in seinem Arbeitsverzeichnis eine Verknüpfung nach <c>/etc</c> an, liegt
|
||||
/// <c>workspace/etc/passwd</c> nach reiner Zeichenkettenrechnung innerhalb, zeigt aber
|
||||
/// hinaus. <see cref="Canonicalize"/> löst deshalb jeden Pfadabschnitt auf.
|
||||
/// </summary>
|
||||
public static class PathBoundary
|
||||
{
|
||||
/// <summary>
|
||||
/// Wie das Dateisystem Pfade vergleicht.
|
||||
///
|
||||
/// Nur Windows und macOS führen Groß- und Kleinschreibung zusammen. Bei macOS ist
|
||||
/// das genau genommen eine Frage des Dateisystems (HFS+/APFS meist ja, aber
|
||||
/// case-sensitive formatierbar) — dort auf der sicheren Seite zu liegen heißt,
|
||||
/// den zusammenführenden Vergleich zu wählen: Er weist im Zweifel zu viel ab,
|
||||
/// statt zu wenig.
|
||||
/// </summary>
|
||||
public static StringComparison Comparison { get; } =
|
||||
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
/// <inheritdoc cref="Comparison"/>
|
||||
public static StringComparer Comparer { get; } =
|
||||
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
|
||||
? StringComparer.OrdinalIgnoreCase
|
||||
: StringComparer.Ordinal;
|
||||
|
||||
/// <summary>Absoluter Pfad mit genau einem abschließenden Trenner.</summary>
|
||||
public static string NormalizeDirectory(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.EndsWith(Path.DirectorySeparatorChar)
|
||||
? full
|
||||
: full + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob <paramref name="candidate"/> im Verzeichnis <paramref name="root"/> liegt.
|
||||
///
|
||||
/// Verglichen wird auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen: Ohne
|
||||
/// den abschließenden Trenner gälte <c>…/Workspace-Backup</c> als Teil von
|
||||
/// <c>…/Workspace</c>.
|
||||
///
|
||||
/// Symbolische Verknüpfungen werden aufgelöst (<see cref="Canonicalize"/>) — ein
|
||||
/// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb.
|
||||
/// </summary>
|
||||
public static bool IsInside(string candidate, string root)
|
||||
{
|
||||
var normalizedRoot = Canonicalize(NormalizeDirectory(root));
|
||||
var normalizedCandidate = Canonicalize(Path.GetFullPath(candidate));
|
||||
|
||||
// Der Root selbst gilt als innerhalb.
|
||||
if (string.Equals(
|
||||
normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar),
|
||||
normalizedRoot.TrimEnd(Path.DirectorySeparatorChar),
|
||||
Comparison))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalizedCandidate.StartsWith(
|
||||
NormalizeDirectory(normalizedRoot), Comparison);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schlüssel für Sperren und Wörterbücher, die einen Pfad eindeutig meinen sollen.
|
||||
///
|
||||
/// Unter Windows werden Schreibweisen zusammengeführt, unter Linux nicht — dort
|
||||
/// sind zwei Schreibweisen zwei Dateien und dürfen sich keine Sperre teilen.
|
||||
/// </summary>
|
||||
public static string CanonicalKey(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return Comparison == StringComparison.OrdinalIgnoreCase
|
||||
? full.ToLowerInvariant()
|
||||
: full;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf.
|
||||
///
|
||||
/// Abschnittsweise, weil eine Verknüpfung mitten im Pfad genügt: Zeigt
|
||||
/// <c>workspace/daten</c> nach <c>/etc</c>, dann liegt <c>workspace/daten/passwd</c>
|
||||
/// außerhalb — obwohl weder der Anfang noch das Ende des Pfades eine Verknüpfung
|
||||
/// ist. <c>ResolveLinkTarget(returnFinalTarget: true)</c> folgt dabei ganzen Ketten,
|
||||
/// ein Aufruf je Abschnitt genügt also.
|
||||
///
|
||||
/// Noch nicht existierende Abschnitte bleiben unverändert — für einen Pfad, der erst
|
||||
/// angelegt werden soll, ist das der richtige Umgang: Was es nicht gibt, kann keine
|
||||
/// Verknüpfung sein, und der bereits vorhandene Teil davor wurde geprüft.
|
||||
/// </summary>
|
||||
public static string Canonicalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
|
||||
var root = Path.GetPathRoot(full);
|
||||
if (string.IsNullOrEmpty(root))
|
||||
return full;
|
||||
|
||||
var rest = full[root.Length..]
|
||||
.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var current = root;
|
||||
|
||||
foreach (var segment in rest)
|
||||
{
|
||||
current = Path.Combine(current, segment);
|
||||
|
||||
string? target;
|
||||
try
|
||||
{
|
||||
// Gibt null zurück, wenn der Abschnitt keine Verknüpfung ist oder
|
||||
// nicht existiert. Wirft bei Zyklen und bei zu tiefen Ketten.
|
||||
target = Directory.Exists(current)
|
||||
? Directory.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName
|
||||
: File.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Zyklus oder unauflösbare Kette. Der Pfad ist damit nicht bestimmbar —
|
||||
// wir geben zurück, was wir haben. Die Einschließungsprüfung entscheidet
|
||||
// dann auf der bisherigen, unaufgelösten Fassung: Sie weist im Zweifel ab.
|
||||
return full;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(target))
|
||||
{
|
||||
current = Path.IsPathRooted(target)
|
||||
? target
|
||||
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(current) ?? root, target));
|
||||
}
|
||||
}
|
||||
|
||||
return Path.GetFullPath(current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Erzeugt Dateinamen, die auf Windows <b>und</b> Linux gültig sind.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): <see cref="Path.GetInvalidFileNameChars"/> liefert
|
||||
/// unter Windows 41 Zeichen, unter Unix genau zwei (<c>\0</c> und <c>/</c>). Wer sich
|
||||
/// darauf verlässt, erzeugt unter Linux Namen wie <c>bericht:2026.zip</c> — dort
|
||||
/// zulässig, unter Windows nicht anlegbar.
|
||||
///
|
||||
/// Das trifft alles, was zwischen Systemen wandert: Sicherungsarchive, Logdateien,
|
||||
/// Task-Dateien im geteilten Arbeitsverzeichnis. Deshalb hier bewusst der strengere
|
||||
/// Maßstab auf beiden Plattformen — ein paar Unterstriche mehr sind billiger als eine
|
||||
/// Sicherung, die sich auf dem Zielsystem nicht auspacken lässt.
|
||||
/// </summary>
|
||||
public static class PortableFileName
|
||||
{
|
||||
/// <summary>Unter Windows unzulässig, unter Unix erlaubt — hier immer ersetzt.</summary>
|
||||
private static readonly char[] WindowsReserved =
|
||||
['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
|
||||
|
||||
/// <summary>
|
||||
/// Gerätenamen, die Windows unabhängig von der Endung nicht als Datei zulässt.
|
||||
/// Unter Linux völlig gewöhnliche Namen — deshalb fällt es dort erst beim
|
||||
/// Zurückspielen auf.
|
||||
/// </summary>
|
||||
private static readonly string[] ReservedNames =
|
||||
[
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
|
||||
];
|
||||
|
||||
public static string Sanitize(string? name, string fallback = "unbenannt")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return fallback;
|
||||
|
||||
var chars = name.Select(c =>
|
||||
c < 32 || WindowsReserved.Contains(c) ? '_' : c).ToArray();
|
||||
|
||||
// Windows schneidet abschließende Punkte und Leerzeichen stillschweigend ab —
|
||||
// aus "bericht." würde "bericht", und zwei Dateien fielen zusammen.
|
||||
var result = new string(chars).TrimEnd('.', ' ');
|
||||
|
||||
if (result.Length == 0)
|
||||
return fallback;
|
||||
|
||||
var stem = Path.GetFileNameWithoutExtension(result);
|
||||
if (ReservedNames.Contains(stem, StringComparer.OrdinalIgnoreCase))
|
||||
result = "_" + result;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,112 @@ public sealed class SqliteStorage
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent
|
||||
ON RunUsage (OccurredAt DESC);
|
||||
|
||||
-- Taskboard (A1): Ausführungszustand der Aufgaben. Die Definition lebt in
|
||||
-- Markdown-Dateien; diese Tabelle spiegelt sie und macht das Claiming atomar.
|
||||
CREATE TABLE IF NOT EXISTS Tasks (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Title TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL DEFAULT 'todo',
|
||||
Type TEXT NOT NULL DEFAULT 'work',
|
||||
Priority INTEGER NOT NULL DEFAULT 3,
|
||||
Assignee TEXT NOT NULL DEFAULT '@human',
|
||||
WhenKind TEXT NULL,
|
||||
WhenValue TEXT NULL,
|
||||
WhenTz TEXT NULL,
|
||||
RequireApproval INTEGER NOT NULL DEFAULT 0,
|
||||
Acceptance TEXT NOT NULL DEFAULT '',
|
||||
-- Blocker als "|a|b|"; die Begrenzer verhindern Teiltreffer bei der Suche.
|
||||
BlockedBy TEXT NOT NULL DEFAULT '',
|
||||
OnlyWhenMarketOpen INTEGER NOT NULL DEFAULT 0,
|
||||
Body TEXT NOT NULL DEFAULT '',
|
||||
FileName TEXT NOT NULL DEFAULT '',
|
||||
-- Nur für Typ tool_job: welches Tool mit welcher Job-Art getickt wird.
|
||||
ToolName TEXT NOT NULL DEFAULT '',
|
||||
JobTypeId TEXT NOT NULL DEFAULT '',
|
||||
-- Ausführungszustand: nur das Board schreibt hier.
|
||||
LastOccurrence TEXT NULL,
|
||||
ClaimToken TEXT NULL,
|
||||
ClaimedAt TEXT NULL,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Tasks_Status
|
||||
ON Tasks (Status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Tasks_Assignee
|
||||
ON Tasks (Assignee);
|
||||
|
||||
-- Audit-Log (A3): ein Eintrag je Tool-Aufruf. Append-only — die Herkunft
|
||||
-- stempelt die Engine, Korrekturen sind neue Zeilen.
|
||||
CREATE TABLE IF NOT EXISTS AuditLog (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
AgentId TEXT NOT NULL,
|
||||
Model TEXT NOT NULL DEFAULT '',
|
||||
Source TEXT NOT NULL DEFAULT 'unknown',
|
||||
Tool TEXT NOT NULL,
|
||||
Arguments TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL,
|
||||
Summary TEXT NOT NULL DEFAULT '',
|
||||
DurationMs INTEGER NOT NULL DEFAULT 0,
|
||||
OccurredAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_AuditLog_Run
|
||||
ON AuditLog (RunId, Id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_AuditLog_Recent
|
||||
ON AuditLog (Id DESC);
|
||||
|
||||
-- Abschluss-Belege (Receipts): ein Beleg je Lauf, verknüpft mit einem Task.
|
||||
CREATE TABLE IF NOT EXISTS RunReceipts (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
AgentId TEXT NOT NULL,
|
||||
Model TEXT NOT NULL DEFAULT '',
|
||||
Source TEXT NOT NULL DEFAULT 'unknown',
|
||||
TaskId TEXT NULL,
|
||||
Status TEXT NOT NULL DEFAULT '',
|
||||
StepCount INTEGER NOT NULL DEFAULT 0,
|
||||
PromptTokens INTEGER NOT NULL DEFAULT 0,
|
||||
CompletionTokens INTEGER NOT NULL DEFAULT 0,
|
||||
CachedTokens INTEGER NOT NULL DEFAULT 0,
|
||||
CostUsd TEXT NOT NULL DEFAULT '0',
|
||||
CostIsKnown INTEGER NOT NULL DEFAULT 0,
|
||||
DurationMs INTEGER NOT NULL DEFAULT 0,
|
||||
ResultRef TEXT NOT NULL DEFAULT '',
|
||||
OccurredAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Task
|
||||
ON RunReceipts (TaskId);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Run
|
||||
ON RunReceipts (RunId);
|
||||
|
||||
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
|
||||
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
|
||||
CREATE TABLE IF NOT EXISTS StagedCalls (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
AgentId TEXT NOT NULL,
|
||||
InstanceId TEXT NOT NULL DEFAULT '',
|
||||
Tool TEXT NOT NULL,
|
||||
Action TEXT NULL,
|
||||
ArgumentsJson TEXT NOT NULL DEFAULT '',
|
||||
Proposal TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL DEFAULT 'Pending',
|
||||
CreatedAt TEXT NOT NULL,
|
||||
DecidedAt TEXT NULL,
|
||||
DecidedBy TEXT NULL,
|
||||
ResultRef TEXT NULL,
|
||||
RejectionReason TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending
|
||||
ON StagedCalls (Status, Id);
|
||||
""";
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet Ordner und Dateien im Dateimanager des Systems.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): Die Oberfläche rief an vier Stellen direkt
|
||||
/// <c>explorer.exe</c> auf. Hier liegt das plattformneutral — und in Core statt in der
|
||||
/// Oberfläche, weil die künftige Avalonia-Fassung dieselben Aufrufe braucht.
|
||||
///
|
||||
/// Bewusst ohne Rückmeldung im Fehlerfall: Einen Ordner zu öffnen ist eine
|
||||
/// Bequemlichkeit. Schlägt es fehl (kein Dateimanager installiert, Dienst ohne
|
||||
/// Sitzung), soll das den Aufrufer nicht beschäftigen.
|
||||
/// </summary>
|
||||
public static class SystemShell
|
||||
{
|
||||
/// <summary>Öffnet ein Verzeichnis im Dateimanager.</summary>
|
||||
public static bool OpenFolder(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||
return false;
|
||||
|
||||
return Open(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet den Ordner einer Datei und hebt sie nach Möglichkeit hervor.
|
||||
///
|
||||
/// Das Hervorheben können nur Windows und macOS. Unter Linux gibt es keinen
|
||||
/// Aufruf, der über alle Dateimanager hinweg funktioniert — dort wird nur der
|
||||
/// Ordner geöffnet. Das ist der kleinere Verlust gegenüber einer Liste von
|
||||
/// Sonderfällen je Desktop-Umgebung.
|
||||
/// </summary>
|
||||
public static bool RevealFile(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return false;
|
||||
|
||||
var folder = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (string.IsNullOrEmpty(folder)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Start("explorer.exe", ["/select,", Path.GetFullPath(path)]);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return Start("open", ["-R", Path.GetFullPath(path)]);
|
||||
}
|
||||
catch { /* siehe Klassenkommentar */ }
|
||||
|
||||
return OpenFolder(folder);
|
||||
}
|
||||
|
||||
private static bool Open(string target)
|
||||
{
|
||||
var full = Path.GetFullPath(target);
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
return Start("xdg-open", [full]);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return Start("open", [full]);
|
||||
|
||||
// Windows: UseShellExecute lässt die Shell entscheiden, statt explorer.exe
|
||||
// festzuschreiben.
|
||||
using var process = Process.Start(new ProcessStartInfo(full)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
return process is not null;
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startet mit <c>ArgumentList</c> statt einer Argumentzeichenkette — so muss nichts
|
||||
/// maskiert werden, und ein Pfad mit Leerzeichen oder Anführungszeichen kann keinen
|
||||
/// zusätzlichen Aufruf einschleusen.
|
||||
/// </summary>
|
||||
private static bool Start(string fileName, string[] arguments)
|
||||
{
|
||||
var info = new ProcessStartInfo(fileName)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
info.ArgumentList.Add(argument);
|
||||
|
||||
using var process = Process.Start(info);
|
||||
return process is not null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user