feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Schritt 4: die Übernahme der alten <c>coordination/*.md</c>-Dateien. Geprüft an den
|
||||
/// tatsächlichen Altformaten (freies Markdown, kein Frontmatter). Kernanspruch: nur echte
|
||||
/// Aufgaben (<c>task_*</c>) wandern ins Board, alles andere bleibt unangetastet, und die
|
||||
/// Migration ist idempotent.
|
||||
/// </summary>
|
||||
public sealed class CoordinationMigrationTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly string _coordinationDir;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteTaskRepository _repo;
|
||||
private readonly TaskboardService _board;
|
||||
private readonly CoordinationMigration _migration;
|
||||
|
||||
public CoordinationMigrationTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
var shared = Path.Combine(_directory, "SharedWorkspace");
|
||||
_coordinationDir = Path.Combine(shared, "coordination");
|
||||
Directory.CreateDirectory(_coordinationDir);
|
||||
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_repo = new SqliteTaskRepository(_storage);
|
||||
_board = new TaskboardService(_repo, Path.Combine(shared, "tasks"));
|
||||
_migration = new CoordinationMigration(_board, _coordinationDir, NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private void Write(string name, string content)
|
||||
=> File.WriteAllText(Path.Combine(_coordinationDir, name), content);
|
||||
|
||||
[Fact]
|
||||
public async Task Nur_task_Dateien_werden_uebernommen()
|
||||
{
|
||||
Write("task_video.md", "# Task: Video-Zusammenfassung\n## Status: ASSIGNED\nAnalysiere das Video.");
|
||||
Write("status_senior.md", "# Senior Developer - Status\n## Status: ONLINE");
|
||||
Write("broadcast.md", "# Broadcast\nBitte meldet euren Status.");
|
||||
Write("incident_x.md", "# Incident Report\nProblem-Chain ...");
|
||||
Write("data.json", "{ \"foo\": 1 }");
|
||||
|
||||
var migrated = await _migration.RunAsync(default);
|
||||
|
||||
migrated.ShouldBe(1);
|
||||
var tasks = await _repo.ListAsync(new TaskQuery { IncludeArchived = true }, default);
|
||||
tasks.Count.ShouldBe(1);
|
||||
tasks[0].Title.ShouldBe("Video-Zusammenfassung");
|
||||
tasks[0].Status.ShouldBe(TaskItemStatus.Backlog, "erst nach menschlicher Sichtung bereit");
|
||||
tasks[0].Assignee.ShouldBe(TaskAssignee.Human);
|
||||
tasks[0].Body.ShouldContain("Analysiere das Video.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_uebernommene_Datei_wandert_nach_migrated_und_bleibt_erhalten()
|
||||
{
|
||||
Write("task_a.md", "# Task: Etwas\nInhalt.");
|
||||
|
||||
await _migration.RunAsync(default);
|
||||
|
||||
File.Exists(Path.Combine(_coordinationDir, "task_a.md")).ShouldBeFalse("aus der obersten Ebene entfernt");
|
||||
File.Exists(Path.Combine(_coordinationDir, "migrated", "task_a.md")).ShouldBeTrue("aber nicht gelöscht");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nicht_Aufgaben_bleiben_unangetastet()
|
||||
{
|
||||
Write("status_x.md", "# Status\nONLINE");
|
||||
Write("report.json", "{}");
|
||||
|
||||
await _migration.RunAsync(default);
|
||||
|
||||
File.Exists(Path.Combine(_coordinationDir, "status_x.md")).ShouldBeTrue();
|
||||
File.Exists(Path.Combine(_coordinationDir, "report.json")).ShouldBeTrue();
|
||||
(await _repo.CountAsync(default)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Migration_ist_idempotent()
|
||||
{
|
||||
Write("task_a.md", "# Task: Einmalig\nInhalt.");
|
||||
|
||||
(await _migration.RunAsync(default)).ShouldBe(1);
|
||||
(await _migration.RunAsync(default)).ShouldBe(0, "zweiter Lauf findet keine task_*-Datei mehr");
|
||||
|
||||
(await _repo.CountAsync(default)).ShouldBe(1, "keine Dublette");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Task_Ueberschrift_kommt_der_Titel_aus_dem_Dateinamen()
|
||||
{
|
||||
Write("task_video_analysis_progress.md", "## Status: IN PROGRESS\nOhne echte Überschrift.");
|
||||
|
||||
await _migration.RunAsync(default);
|
||||
|
||||
var tasks = await _repo.ListAsync(new TaskQuery(), default);
|
||||
tasks[0].Title.ShouldBe("video analysis progress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_coordination_Verzeichnis_passiert_nichts()
|
||||
{
|
||||
Directory.Delete(_coordinationDir, recursive: true);
|
||||
(await _migration.RunAsync(default)).ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Der Assignee bestimmt, wie eine fällige Aufgabe zu einem Lauf wird (T7). Die Deutung
|
||||
/// muss eindeutig sein — davon hängt ab, ob ein Agent frisch oder mit Kontext läuft.
|
||||
/// </summary>
|
||||
public sealed class TaskAssigneeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("@human", TaskAssigneeKind.Human)]
|
||||
[InlineData("@new", TaskAssigneeKind.New)]
|
||||
[InlineData("@new:crawler", TaskAssigneeKind.New)]
|
||||
[InlineData("@crawler", TaskAssigneeKind.Agent)]
|
||||
public void Die_Art_wird_richtig_erkannt(string assignee, TaskAssigneeKind expected)
|
||||
=> TaskAssignee.KindOf(assignee).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("@crawler", "crawler")]
|
||||
[InlineData("@new:crawler", "crawler")]
|
||||
[InlineData("@new", "")] // frischer Lauf ohne benannten Agenten
|
||||
[InlineData("@human", "")]
|
||||
public void Die_Agent_Id_wird_richtig_herausgezogen(string assignee, string expected)
|
||||
=> TaskAssignee.AgentId(assignee).ShouldBe(expected);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Der Frontmatter-Parser ist die Brücke zwischen der menschen-/agentenlesbaren
|
||||
/// Task-Datei und dem DB-Zustand. Was er falsch liest, importiert das Board falsch —
|
||||
/// deshalb steht hier der Rundlauf im Mittelpunkt: Serialisieren und wieder Einlesen
|
||||
/// muss dieselbe Definition ergeben.
|
||||
/// </summary>
|
||||
public sealed class TaskFrontmatterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Ein_vollstaendiges_Frontmatter_wird_gelesen()
|
||||
{
|
||||
var text = """
|
||||
---
|
||||
id: t-8f3a2c
|
||||
title: NVDA Earnings recherchieren
|
||||
status: todo
|
||||
type: work
|
||||
priority: 4
|
||||
assignee: "@crawler"
|
||||
when:
|
||||
kind: cron
|
||||
value: "0 7 * * 1-5"
|
||||
tz: Europe/Berlin
|
||||
require_approval: true
|
||||
acceptance: |
|
||||
Aktuelle Zahlen mit Datum und Quelle.
|
||||
In SharedWorkspace/data/nvda.json abgelegt.
|
||||
blocked_by: [t-4b1e, t-9a2c]
|
||||
onlyWhenMarketOpen: false
|
||||
---
|
||||
|
||||
Recherchiere die neuesten Quartalszahlen.
|
||||
""";
|
||||
|
||||
TaskFrontmatter.TryParse(text, out var task, out var error).ShouldBeTrue(error);
|
||||
|
||||
task.Id.ShouldBe("t-8f3a2c");
|
||||
task.Title.ShouldBe("NVDA Earnings recherchieren");
|
||||
task.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
task.Type.ShouldBe(TaskItemType.Work);
|
||||
task.Priority.ShouldBe(4);
|
||||
task.Assignee.ShouldBe("@crawler");
|
||||
task.When.ShouldNotBeNull();
|
||||
task.When!.Kind.ShouldBe(TaskWhenKind.Cron);
|
||||
task.When.Value.ShouldBe("0 7 * * 1-5");
|
||||
task.When.TimeZone.ShouldBe("Europe/Berlin");
|
||||
task.RequireApproval.ShouldBeTrue();
|
||||
task.Acceptance.ShouldContain("nvda.json");
|
||||
task.Acceptance.ShouldContain("Aktuelle Zahlen");
|
||||
task.BlockedBy.ShouldBe(["t-4b1e", "t-9a2c"]);
|
||||
task.OnlyWhenMarketOpen.ShouldBeFalse();
|
||||
task.Body.ShouldBe("Recherchiere die neuesten Quartalszahlen.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialisieren_und_wieder_Einlesen_ergibt_dieselbe_Definition()
|
||||
{
|
||||
var original = new TaskItem
|
||||
{
|
||||
Id = "t-abc123",
|
||||
Title = "Titel mit: Doppelpunkt und # Raute",
|
||||
Status = TaskItemStatus.InReview,
|
||||
Type = TaskItemType.Approval,
|
||||
Priority = 5,
|
||||
Assignee = "@human",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" },
|
||||
RequireApproval = true,
|
||||
Acceptance = "Zeile eins\nZeile zwei",
|
||||
BlockedBy = ["t-1", "t-2"],
|
||||
OnlyWhenMarketOpen = true,
|
||||
Body = "Mehrzeiliger\nRumpf mit Umlauten äöü und 🦀."
|
||||
};
|
||||
|
||||
var text = TaskFrontmatter.Serialize(original);
|
||||
TaskFrontmatter.TryParse(text, out var round, out var error).ShouldBeTrue(error);
|
||||
|
||||
round.Id.ShouldBe(original.Id);
|
||||
round.Title.ShouldBe(original.Title);
|
||||
round.Status.ShouldBe(original.Status);
|
||||
round.Type.ShouldBe(original.Type);
|
||||
round.Priority.ShouldBe(original.Priority);
|
||||
round.Assignee.ShouldBe(original.Assignee);
|
||||
round.When!.Kind.ShouldBe(TaskWhenKind.Every);
|
||||
round.When.Value.ShouldBe("30m");
|
||||
round.When.TimeZone.ShouldBe("UTC");
|
||||
round.RequireApproval.ShouldBeTrue();
|
||||
round.Acceptance.ShouldBe(original.Acceptance);
|
||||
round.BlockedBy.ShouldBe(["t-1", "t-2"]);
|
||||
round.OnlyWhenMarketOpen.ShouldBeTrue();
|
||||
round.Body.ShouldBe(original.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_Assignee_mit_At_ueberlebt_den_Rundlauf()
|
||||
{
|
||||
// In YAML ist ein führendes '@' reserviert — ohne Quotierung ginge es verloren.
|
||||
var task = new TaskItem { Id = "t-1", Title = "x", Assignee = "@new" };
|
||||
|
||||
var text = TaskFrontmatter.Serialize(task);
|
||||
TaskFrontmatter.TryParse(text, out var round, out _).ShouldBeTrue();
|
||||
|
||||
round.Assignee.ShouldBe("@new");
|
||||
TaskAssignee.KindOf(round.Assignee).ShouldBe(TaskAssigneeKind.New);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ohne_when_ist_es_eine_einmalige_Aufgabe()
|
||||
{
|
||||
var text = """
|
||||
---
|
||||
id: t-1
|
||||
title: Einmalig
|
||||
assignee: "@new"
|
||||
---
|
||||
Mach das einmal.
|
||||
""";
|
||||
|
||||
TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue();
|
||||
task.When.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fehlende_Felder_fallen_auf_sichere_Standards_zurueck()
|
||||
{
|
||||
var text = """
|
||||
---
|
||||
id: t-1
|
||||
title: Minimal
|
||||
---
|
||||
""";
|
||||
|
||||
TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue();
|
||||
|
||||
task.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
task.Type.ShouldBe(TaskItemType.Work);
|
||||
task.Priority.ShouldBe(3);
|
||||
task.Assignee.ShouldBe(TaskAssignee.Human, "ohne Zuordnung wartet die Aufgabe auf einen Menschen");
|
||||
task.RequireApproval.ShouldBeFalse();
|
||||
task.BlockedBy.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eine_unbekannte_Terminart_wird_verworfen_statt_geraten()
|
||||
{
|
||||
var text = """
|
||||
---
|
||||
id: t-1
|
||||
title: x
|
||||
when:
|
||||
kind: phantasie
|
||||
value: irgendwas
|
||||
tz: UTC
|
||||
---
|
||||
""";
|
||||
|
||||
TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue();
|
||||
task.When.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fehlt_der_Frontmatter_Block_meldet_der_Parser_das()
|
||||
{
|
||||
TaskFrontmatter.TryParse("Nur Text, kein Frontmatter.", out _, out var error).ShouldBeFalse();
|
||||
error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_nicht_geschlossener_Block_wird_gemeldet()
|
||||
{
|
||||
var text = "---\nid: t-1\ntitle: x\n";
|
||||
TaskFrontmatter.TryParse(text, out _, out var error).ShouldBeFalse();
|
||||
error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows_Zeilenenden_stoeren_nicht()
|
||||
{
|
||||
var text = "---\r\nid: t-1\r\ntitle: Mit CRLF\r\n---\r\nRumpf\r\n";
|
||||
TaskFrontmatter.TryParse(text, out var task, out var error).ShouldBeTrue(error);
|
||||
task.Title.ShouldBe("Mit CRLF");
|
||||
task.Body.ShouldBe("Rumpf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// A1 Taskboard: der Ausführungszustand. Getestet gegen eine echte SQLite-Datei, weil es
|
||||
/// gerade um Schema, Sperrverhalten und atomares Claiming geht — eine Attrappe würde
|
||||
/// genau das verstecken, worauf es ankommt.
|
||||
///
|
||||
/// Die drei Invarianten aus dem Taskboard-Konzept stehen im Mittelpunkt: nie zwei Claims
|
||||
/// auf einen Termin, kein Dispatch bei offenem Blocker, doppelter Tick = ein Lauf.
|
||||
/// </summary>
|
||||
public sealed class TaskRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteTaskRepository _repo;
|
||||
|
||||
public TaskRepositoryTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_repo = new SqliteTaskRepository(_storage);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private Task<TaskItem> Seed(
|
||||
string id, TaskItemStatus status = TaskItemStatus.Todo,
|
||||
string assignee = "@new", int priority = 3,
|
||||
string title = "Aufgabe", IReadOnlyList<string>? blockedBy = null)
|
||||
=> _repo.UpsertAsync(new TaskItem
|
||||
{
|
||||
Id = id,
|
||||
Title = title,
|
||||
Status = status,
|
||||
Assignee = assignee,
|
||||
Priority = priority,
|
||||
BlockedBy = blockedBy ?? []
|
||||
}, default);
|
||||
|
||||
private static string Occ(DateTime t) => t.ToUniversalTime().ToString("O");
|
||||
private static readonly DateTime T0 = new(2026, 7, 31, 7, 0, 0, DateTimeKind.Utc);
|
||||
private static DateTime Now => DateTime.UtcNow;
|
||||
private static DateTime NoStaleClaims => Now.AddHours(-1); // Lease-Cutoff weit in der Vergangenheit
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Grundfunktionen: Import (Upsert), Lesen, Auflisten
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Aufgabe_wird_angelegt_und_wiedergefunden()
|
||||
{
|
||||
await Seed("t-1", title: "Recherche");
|
||||
|
||||
var found = await _repo.GetAsync("t-1", default);
|
||||
|
||||
found.ShouldNotBeNull();
|
||||
found!.Title.ShouldBe("Recherche");
|
||||
found.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Import_ist_idempotent_und_erzeugt_keine_Dublette()
|
||||
{
|
||||
await Seed("t-1", title: "Erste Fassung");
|
||||
await Seed("t-1", title: "Zweite Fassung");
|
||||
|
||||
(await _repo.CountAsync(default)).ShouldBe(1);
|
||||
(await _repo.GetAsync("t-1", default))!.Title.ShouldBe("Zweite Fassung");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Re_Import_laesst_den_Ausfuehrungszustand_unangetastet()
|
||||
{
|
||||
// Der springende Punkt der Wahrheitsaufteilung: Die Datei ist Wahrheit über die
|
||||
// Definition, die DB über den Ausführungszustand. Ein erneuter Import darf einen
|
||||
// laufenden oder abgeschlossenen Zustand nicht zurücksetzen.
|
||||
await Seed("t-1");
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
|
||||
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Done, Now, default);
|
||||
|
||||
// Datei wird erneut importiert (Titel geändert, Status im Frontmatter noch "todo").
|
||||
await _repo.UpsertAsync(new TaskItem { Id = "t-1", Title = "geändert", Status = TaskItemStatus.Todo }, default);
|
||||
|
||||
var after = await _repo.GetAsync("t-1", default);
|
||||
after!.Title.ShouldBe("geändert", "die Definition wird übernommen");
|
||||
after.Status.ShouldBe(TaskItemStatus.Done, "der Ausführungszustand bleibt");
|
||||
after.LastOccurrence.ShouldBe(Occ(T0), "der Marker bleibt erhalten");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Liste_filtert_nach_Status_und_blendet_Archiviertes_aus()
|
||||
{
|
||||
await Seed("t-1", TaskItemStatus.Todo);
|
||||
await Seed("t-2", TaskItemStatus.Done);
|
||||
await Seed("t-3", TaskItemStatus.Archived);
|
||||
|
||||
(await _repo.ListAsync(new TaskQuery { Status = TaskItemStatus.Todo }, default)).Count.ShouldBe(1);
|
||||
(await _repo.ListAsync(new TaskQuery(), default)).Count.ShouldBe(2, "Archiviertes ist standardmäßig ausgeblendet");
|
||||
(await _repo.ListAsync(new TaskQuery { IncludeArchived = true }, default)).Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Liste_ordnet_nach_Prioritaet()
|
||||
{
|
||||
await Seed("t-low", priority: 1, title: "nebensächlich");
|
||||
await Seed("t-high", priority: 5, title: "dringend");
|
||||
await Seed("t-mid", priority: 3, title: "mittel");
|
||||
|
||||
var list = await _repo.ListAsync(new TaskQuery(), default);
|
||||
|
||||
list[0].Title.ShouldBe("dringend");
|
||||
list[^1].Title.ShouldBe("nebensächlich");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Liste_filtert_nach_Assignee_und_Freitext()
|
||||
{
|
||||
await Seed("t-1", assignee: "@crawler", title: "NVDA Zahlen");
|
||||
await Seed("t-2", assignee: "@analyst", title: "TSLA Bericht");
|
||||
|
||||
(await _repo.ListAsync(new TaskQuery { Assignee = "@crawler" }, default)).Count.ShouldBe(1);
|
||||
(await _repo.ListAsync(new TaskQuery { Search = "nvda" }, default)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Invariante 1 & 3: nie zwei Claims, doppelter Tick = ein Lauf
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Termin_laesst_sich_genau_einmal_beanspruchen()
|
||||
{
|
||||
await Seed("t-1");
|
||||
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok-a", Now, NoStaleClaims, default)).ShouldBeTrue();
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok-b", Now, NoStaleClaims, default))
|
||||
.ShouldBeFalse("der Termin ist bereits beansprucht");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gleichzeitige_Claims_auf_denselben_Termin_ergeben_genau_einen_Gewinner()
|
||||
{
|
||||
// Die Kern-Invariante. Mehrfach wiederholt, weil ein Race sporadisch auftritt.
|
||||
for (var round = 0; round < 25; round++)
|
||||
{
|
||||
var id = $"t-race-{round}";
|
||||
await Seed(id);
|
||||
|
||||
var attempts = Enumerable.Range(0, 32)
|
||||
.Select(i => _repo.TryClaimAsync(id, Occ(T0), $"tok-{i}", Now, NoStaleClaims, default));
|
||||
|
||||
var results = await Task.WhenAll(attempts);
|
||||
|
||||
results.Count(won => won).ShouldBe(1, $"Runde {round}: genau ein Lauf darf den Termin ziehen");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_zweiter_Tick_auf_denselben_Termin_loest_keinen_zweiten_Lauf_aus()
|
||||
{
|
||||
await Seed("t-1");
|
||||
|
||||
// Erster Tick beansprucht und schließt ab.
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
|
||||
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
|
||||
|
||||
// Zweiter Tick im selben Terminfenster: derselbe Occurrence-Key.
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default))
|
||||
.ShouldBeFalse("derselbe Termin darf nach Abschluss nicht erneut laufen");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_gescheiterter_Lauf_wird_nicht_automatisch_wiederholt()
|
||||
{
|
||||
// Kein Retry-Sturm: Der Marker steht schon beim Claim, nicht erst beim Erfolg.
|
||||
await Seed("t-1");
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
|
||||
|
||||
// Lauf scheitert → zurück auf todo, aber derselbe Termin bleibt verbraucht.
|
||||
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
|
||||
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default))
|
||||
.ShouldBeFalse("derselbe Termin wird nicht erneut versucht");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_naechste_planmaessige_Termin_laesst_sich_wieder_beanspruchen()
|
||||
{
|
||||
await Seed("t-1");
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
|
||||
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
|
||||
|
||||
// Ein späterer Occurrence-Key liegt über dem Marker → wieder fällig.
|
||||
var later = Occ(T0.AddDays(1));
|
||||
(await _repo.TryClaimAsync("t-1", later, "tok3", Now, NoStaleClaims, default))
|
||||
.ShouldBeTrue("ein neuer Termin darf laufen");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Invariante 2: kein Dispatch bei offenem Blocker
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_blockierte_Aufgabe_laesst_sich_nicht_beanspruchen()
|
||||
{
|
||||
await Seed("t-1", TaskItemStatus.Blocked, blockedBy: ["t-0"]);
|
||||
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default))
|
||||
.ShouldBeFalse("solange der Blocker offen ist, läuft nichts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wartende_Aufgaben_lassen_sich_ueber_ihren_Blocker_finden()
|
||||
{
|
||||
await Seed("t-a", blockedBy: ["t-blocker"]);
|
||||
await Seed("t-b", blockedBy: ["t-blocker", "t-anderer"]);
|
||||
await Seed("t-c", blockedBy: ["t-blocker10"]);
|
||||
|
||||
var waiting = await _repo.ListBlockedByAsync("t-blocker", default);
|
||||
|
||||
waiting.Select(t => t.Id).OrderBy(x => x).ShouldBe(["t-a", "t-b"]);
|
||||
waiting.ShouldNotContain(t => t.Id == "t-c", "t-blocker darf nicht t-blocker10 treffen");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Claim-Abschluss, Lease und Reconciliation
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Lauf_ohne_gueltigen_Claim_kann_nichts_ueberschreiben()
|
||||
{
|
||||
await Seed("t-1");
|
||||
(await _repo.TryClaimAsync("t-1", Occ(T0), "echt", Now, NoStaleClaims, default)).ShouldBeTrue();
|
||||
|
||||
(await _repo.CompleteClaimAsync("t-1", "falsch", TaskItemStatus.Done, Now, default))
|
||||
.ShouldBeFalse("ein fremdes Token darf den Abschluss nicht setzen");
|
||||
(await _repo.CompleteClaimAsync("t-1", "echt", TaskItemStatus.Done, Now, default))
|
||||
.ShouldBeTrue();
|
||||
|
||||
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verwaiste_Claims_werden_beim_Start_zurueckgesetzt()
|
||||
{
|
||||
await Seed("t-stuck");
|
||||
// Claim mit einem Zeitpunkt, der bereits weit zurückliegt (abgestürzter Lauf).
|
||||
var longAgo = Now.AddMinutes(-30);
|
||||
(await _repo.TryClaimAsync("t-stuck", Occ(T0), "tok", longAgo, longAgo.AddMinutes(-1), default)).ShouldBeTrue();
|
||||
|
||||
// Reconciliation: Claims älter als 5 Minuten gelten als verwaist.
|
||||
var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default);
|
||||
|
||||
reset.ShouldBe(1);
|
||||
var after = await _repo.GetAsync("t-stuck", default);
|
||||
after!.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
after.ClaimToken.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_frischer_Claim_wird_bei_der_Reconciliation_nicht_angetastet()
|
||||
{
|
||||
await Seed("t-live");
|
||||
(await _repo.TryClaimAsync("t-live", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
|
||||
|
||||
var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default);
|
||||
|
||||
reset.ShouldBe(0, "ein laufender Lauf darf nicht abgeräumt werden");
|
||||
(await _repo.GetAsync("t-live", default))!.Status.ShouldBe(TaskItemStatus.InProgress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Status_laesst_sich_direkt_setzen()
|
||||
{
|
||||
await Seed("t-1");
|
||||
(await _repo.SetStatusAsync("t-1", TaskItemStatus.Canceled, Now, default)).ShouldBeTrue();
|
||||
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Canceled);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Nebenläufigkeit und Persistenz
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Viele_Aufgaben_lassen_sich_gleichzeitig_importieren()
|
||||
{
|
||||
var imports = Enumerable.Range(0, 60).Select(i => Seed($"t-{i}"));
|
||||
await Task.WhenAll(imports);
|
||||
|
||||
(await _repo.CountAsync(default)).ShouldBe(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aufgaben_ueberdauern_das_Schliessen_der_Datenbank()
|
||||
{
|
||||
await Seed("t-1", title: "muss einen Neustart überleben");
|
||||
|
||||
var reopened = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
|
||||
|
||||
(await reopened.GetAsync("t-1", default))!.Title.ShouldBe("muss einen Neustart überleben");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Der Scanner-Kern — der in der Roadmap für Opus 5/Fable markierte heikle Teil. Getestet
|
||||
/// gegen echte SQLite (das atomare Claiming ist der Punkt) mit einer Attrappe für die
|
||||
/// Ausführung, damit keine Engine nötig ist. Die drei Invarianten stehen im Mittelpunkt.
|
||||
/// </summary>
|
||||
public sealed class TaskScannerTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteTaskRepository _repo;
|
||||
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 7, 31, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
public TaskScannerTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_repo = new SqliteTaskRepository(_storage);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private TaskScanner Scanner(ITaskDispatcher dispatcher, IMarketCalendar? market = null)
|
||||
=> new(_repo, dispatcher, NullLoggerFactory.Instance, market, _clock);
|
||||
|
||||
/// <summary>Eine sofort fällige Aufgabe (kein Termin, nie gelaufen).</summary>
|
||||
private Task<TaskItem> SeedDue(
|
||||
string id, string assignee = "@new",
|
||||
TaskItemStatus status = TaskItemStatus.Todo,
|
||||
bool requireApproval = false, IReadOnlyList<string>? blockedBy = null,
|
||||
bool onlyWhenMarketOpen = false)
|
||||
=> _repo.UpsertAsync(new TaskItem
|
||||
{
|
||||
Id = id,
|
||||
Title = id,
|
||||
Assignee = assignee,
|
||||
Status = status,
|
||||
RequireApproval = requireApproval,
|
||||
BlockedBy = blockedBy ?? [],
|
||||
OnlyWhenMarketOpen = onlyWhenMarketOpen
|
||||
}, default);
|
||||
|
||||
// ─── Grundfluss ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_faellige_Aufgabe_wird_ausgefuehrt_und_abgeschlossen()
|
||||
{
|
||||
await SeedDue("t-1");
|
||||
var dispatcher = new FakeDispatcher();
|
||||
|
||||
var dispatched = await Scanner(dispatcher).ScanOnceAsync(default);
|
||||
|
||||
dispatched.ShouldBe(1);
|
||||
dispatcher.Dispatched.ShouldBe(["t-1"]);
|
||||
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mit_require_approval_landet_das_Ergebnis_im_Review()
|
||||
{
|
||||
await SeedDue("t-1", requireApproval: true);
|
||||
|
||||
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
|
||||
|
||||
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.InReview);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_gescheiterter_Lauf_geht_zurueck_auf_todo_und_wird_nicht_wiederholt()
|
||||
{
|
||||
await SeedDue("t-1");
|
||||
var dispatcher = new FakeDispatcher(succeed: false);
|
||||
var scanner = Scanner(dispatcher);
|
||||
|
||||
await scanner.ScanOnceAsync(default);
|
||||
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
|
||||
// Zweiter Takt: der Marker ist gesetzt, derselbe Termin läuft nicht erneut.
|
||||
await scanner.ScanOnceAsync(default);
|
||||
dispatcher.Dispatched.Count.ShouldBe(1, "kein Retry-Sturm für denselben Termin");
|
||||
}
|
||||
|
||||
// ─── Invariante 2: kein Dispatch bei offenem Blocker / an Menschen ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_blockierte_Aufgabe_wird_nicht_angestossen()
|
||||
{
|
||||
await SeedDue("t-1", status: TaskItemStatus.Blocked, blockedBy: ["t-0"]);
|
||||
var dispatcher = new FakeDispatcher();
|
||||
|
||||
(await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0);
|
||||
dispatcher.Dispatched.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Aufgabe_fuer_einen_Menschen_wird_nicht_angestossen()
|
||||
{
|
||||
await SeedDue("t-1", assignee: "@human");
|
||||
var dispatcher = new FakeDispatcher();
|
||||
|
||||
(await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0);
|
||||
dispatcher.Dispatched.ShouldBeEmpty();
|
||||
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo, "sie wartet unverändert");
|
||||
}
|
||||
|
||||
// ─── Invariante 3: doppelter Takt = ein Lauf ───
|
||||
|
||||
[Fact]
|
||||
public async Task Zwei_gleichzeitige_Takte_stossen_eine_Aufgabe_nur_einmal_an()
|
||||
{
|
||||
for (var round = 0; round < 20; round++)
|
||||
{
|
||||
var id = $"t-{round}";
|
||||
await SeedDue(id);
|
||||
var dispatcher = new FakeDispatcher();
|
||||
var scanner = Scanner(dispatcher);
|
||||
|
||||
await Task.WhenAll(
|
||||
scanner.ScanOnceAsync(default),
|
||||
scanner.ScanOnceAsync(default));
|
||||
|
||||
dispatcher.Dispatched.Count(x => x == id).ShouldBe(1, $"Runde {round}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wiederkehrende Tasks (ersetzt den Alt-Scheduler) ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_wiederkehrender_Task_bleibt_nach_dem_Feuern_auf_todo()
|
||||
{
|
||||
_clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero));
|
||||
await _repo.UpsertAsync(new TaskItem
|
||||
{
|
||||
Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo,
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" }
|
||||
}, default);
|
||||
|
||||
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
|
||||
|
||||
(await _repo.GetAsync("t-rec", default))!.Status.ShouldBe(
|
||||
TaskItemStatus.Todo, "sonst liefe ein Cron-Task nur ein einziges Mal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_wiederkehrender_Task_feuert_an_jedem_Termin_erneut()
|
||||
{
|
||||
await _repo.UpsertAsync(new TaskItem
|
||||
{
|
||||
Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo,
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" }
|
||||
}, default);
|
||||
var dispatcher = new FakeDispatcher();
|
||||
var scanner = Scanner(dispatcher);
|
||||
|
||||
_clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero));
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
_clock.SetUtcNow(new DateTimeOffset(2027, 1, 2, 9, 0, 0, TimeSpan.Zero));
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
dispatcher.Dispatched.Count(x => x == "t-rec").ShouldBe(2, "zwei Termine, zwei Läufe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_backlog_Task_wird_nicht_ausgefuehrt()
|
||||
{
|
||||
await SeedDue("t-1", status: TaskItemStatus.Backlog);
|
||||
var dispatcher = new FakeDispatcher();
|
||||
|
||||
(await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0);
|
||||
dispatcher.Dispatched.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ─── C1-Haken: Marktkalender ───
|
||||
|
||||
[Fact]
|
||||
public async Task Bei_geschlossenem_Markt_bleibt_ein_marktabhaengiger_Termin_liegen()
|
||||
{
|
||||
await SeedDue("t-1", onlyWhenMarketOpen: true);
|
||||
var dispatcher = new FakeDispatcher();
|
||||
|
||||
var scanner = Scanner(dispatcher, new ClosedMarket());
|
||||
(await scanner.ScanOnceAsync(default)).ShouldBe(0);
|
||||
|
||||
dispatcher.Dispatched.ShouldBeEmpty();
|
||||
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
}
|
||||
|
||||
// ─── Auto-Dispatch: Blocker fertig → Wartende frei ───
|
||||
|
||||
[Fact]
|
||||
public async Task Wird_der_letzte_Blocker_fertig_gibt_das_die_wartende_Aufgabe_frei()
|
||||
{
|
||||
await SeedDue("t-blocker");
|
||||
await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-blocker"]);
|
||||
|
||||
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
|
||||
|
||||
(await _repo.GetAsync("t-blocker", default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
(await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe(
|
||||
TaskItemStatus.Todo, "der Blocker ist erledigt, also ist sie jetzt bereit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Solange_ein_Blocker_offen_ist_bleibt_die_wartende_Aufgabe_blockiert()
|
||||
{
|
||||
await SeedDue("t-b1"); // wird in diesem Takt fertig
|
||||
await SeedDue("t-b2", assignee: "@human"); // ein Mensch — bleibt offen
|
||||
await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-b1", "t-b2"]);
|
||||
|
||||
await Scanner(new FakeDispatcher()).ScanOnceAsync(default);
|
||||
|
||||
(await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe(
|
||||
TaskItemStatus.Blocked, "nicht alle Blocker sind erledigt");
|
||||
}
|
||||
|
||||
// ─── Attrappen ───
|
||||
|
||||
private sealed class FakeDispatcher(bool succeed = true) : ITaskDispatcher
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
public List<string> Dispatched { get; } = [];
|
||||
|
||||
public Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
Dispatched.Add(task.Id);
|
||||
return Task.FromResult(succeed);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ClosedMarket : IMarketCalendar
|
||||
{
|
||||
public bool IsOpen(DateTime nowUtc) => false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Die Terminberechnung — Antwort auf B6 (keine Delays, nur „was ist jetzt fällig")
|
||||
/// und B7 (jeder Termin wird explizit in seiner Zeitzone gedeutet). Reine Funktion,
|
||||
/// deshalb ohne DB und ohne Uhr-Attrappe: „jetzt" wird direkt übergeben.
|
||||
/// </summary>
|
||||
public sealed class TaskScheduleTests
|
||||
{
|
||||
private static DateTime Utc(int y, int mo, int d, int h, int mi)
|
||||
=> new(y, mo, d, h, mi, 0, DateTimeKind.Utc);
|
||||
|
||||
// ─── Einmalig, ohne Termin ───
|
||||
|
||||
[Fact]
|
||||
public void Ohne_Termin_ist_die_Aufgabe_faellig_bis_sie_lief()
|
||||
{
|
||||
var task = new TaskItem { Id = "t-1", CreatedAt = Utc(2026, 7, 31, 6, 0) };
|
||||
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 0)).ShouldNotBeNull();
|
||||
TaskSchedule.DueOccurrence(task with { LastOccurrence = "x" }, Utc(2026, 7, 31, 7, 0)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ─── at: einmaliger Zeitpunkt ───
|
||||
|
||||
[Fact]
|
||||
public void Ein_at_Termin_ist_erst_ab_dem_Zeitpunkt_faellig()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// 09:00 Berlin (Sommerzeit) = 07:00 UTC
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 59)).ShouldBeNull();
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 0)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_at_Termin_feuert_genau_einmal()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
LastOccurrence = "2026-07-31T07:00:00.0000000Z",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 12, 0)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_at_Termin_mit_Z_wird_als_UTC_gedeutet()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00Z", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 8, 59)).ShouldBeNull();
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 0)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ─── every: Intervall ───
|
||||
|
||||
[Fact]
|
||||
public void Ein_Intervall_wird_erst_nach_Ablauf_faellig()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
CreatedAt = Utc(2026, 7, 31, 7, 0),
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
|
||||
};
|
||||
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 20)).ShouldBeNull();
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 30)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nach_dem_Marker_zaehlt_das_Intervall_neu()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
LastOccurrence = "2026-07-31T07:30:00.0000000Z",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
|
||||
};
|
||||
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 55)).ShouldBeNull();
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 8, 0)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ─── cron: mit Zeitzone (der Kern von B7) ───
|
||||
|
||||
[Fact]
|
||||
public void Ein_Cron_Termin_wird_in_seiner_Zeitzone_gedeutet_Sommer()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
CreatedAt = Utc(2026, 7, 31, 0, 0),
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// 09:00 Berlin im Sommer (CEST, UTC+2) = 07:00 UTC
|
||||
var occ = TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 5));
|
||||
occ.ShouldBe("2026-07-31T07:00:00.0000000Z");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derselbe_Cron_liegt_im_Winter_auf_einer_anderen_UTC_Stunde()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
CreatedAt = Utc(2026, 1, 15, 0, 0),
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// 09:00 Berlin im Winter (CET, UTC+1) = 08:00 UTC — genau das, was B7 verlangt.
|
||||
var occ = TaskSchedule.DueOccurrence(task, Utc(2026, 1, 15, 8, 5));
|
||||
occ.ShouldBe("2026-01-15T08:00:00.0000000Z");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vor_der_geplanten_Zeit_ist_der_Cron_nicht_faellig()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
CreatedAt = Utc(2026, 7, 31, 5, 0),
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// 06:00 UTC = 08:00 Berlin, vor der 09:00-Marke
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 0)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eine_frische_Cron_Aufgabe_holt_keinen_Termin_von_vor_ihrer_Anlage_nach()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
CreatedAt = Utc(2026, 7, 31, 8, 0), // nach der heutigen 09:00-Berlin-Marke (07:00 UTC)
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// Jetzt 10:00 UTC — die heutige 09:00-Marke lag vor der Anlage, also nicht nachholen.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derselbe_Cron_Termin_feuert_nicht_zweimal()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
LastOccurrence = "2026-07-31T07:00:00.0000000Z",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
// Später am selben Tag: der 09:00-Termin ist schon abgehakt.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull();
|
||||
|
||||
// Der nächste Tag ist wieder fällig.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 8, 1, 7, 5)).ShouldBe("2026-08-01T07:00:00.0000000Z");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_kaputter_Cron_Ausdruck_kippt_den_Scanner_nicht()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "kein cron", TimeZone = "UTC" }
|
||||
};
|
||||
|
||||
Should.NotThrow(() => TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 0)))
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Früher fiel eine unbekannte Zone still auf UTC zurück. Das ist der schlechtere
|
||||
/// Fehler: Ein Task für 09:00 Ortszeit lief im Sommer um 07:00, ohne dass irgendwo
|
||||
/// etwas auffiel. Jetzt feuert er gar nicht — und der Scanner meldet einmal, warum.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Eine_unbekannte_Zeitzone_laesst_den_Termin_nicht_faellig_werden()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
CreatedAt = Utc(2026, 7, 31, 0, 0),
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Phantasie/Ort" }
|
||||
};
|
||||
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5)).ShouldBeNull();
|
||||
TaskSchedule.UnresolvableTimeZone(task).ShouldBe("Phantasie/Ort");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Kern der Portierung: Dieselbe Zeitzone, zwei Schreibweisen. Eine Task-Datei,
|
||||
/// die unter Windows entstanden ist, muss unter Linux dieselbe Feuerzeit ergeben —
|
||||
/// und umgekehrt.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Europe/Berlin")]
|
||||
[InlineData("W. Europe Standard Time")]
|
||||
public void Beide_Schreibweisen_derselben_Zone_ergeben_dieselbe_Feuerzeit(string tz)
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
CreatedAt = Utc(2026, 7, 31, 0, 0),
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = tz }
|
||||
};
|
||||
|
||||
TaskSchedule.UnresolvableTimeZone(task).ShouldBeNull();
|
||||
|
||||
// 09:00 Ortszeit im Sommer (MESZ, UTC+2) = 07:00 UTC.
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5))
|
||||
.ShouldBe("2026-07-31T07:00:00.0000000Z");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keine_Zeitzonenangabe_bedeutet_weiterhin_UTC()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
CreatedAt = Utc(2026, 7, 31, 0, 0),
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "" }
|
||||
};
|
||||
|
||||
TaskSchedule.UnresolvableTimeZone(task).ShouldBeNull();
|
||||
TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5))
|
||||
.ShouldBe("2026-07-31T09:00:00.0000000Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Die Wahrheitsaufteilung in Aktion: Der Dienst hält Markdown-Datei (Definition) und
|
||||
/// DB (Ausführungszustand) im Gleichschritt. Getestet gegen echtes Dateisystem und echte
|
||||
/// SQLite — beide Seiten sind der Punkt.
|
||||
/// </summary>
|
||||
public sealed class TaskboardServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly string _tasksDir;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteTaskRepository _repo;
|
||||
private readonly TaskboardService _board;
|
||||
|
||||
public TaskboardServiceTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
_tasksDir = Path.Combine(_directory, "SharedWorkspace", "tasks");
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_repo = new SqliteTaskRepository(_storage);
|
||||
_board = new TaskboardService(_repo, _tasksDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private static TaskItem Def(string title = "Aufgabe", string assignee = "@new")
|
||||
=> new() { Title = title, Assignee = assignee, Body = "Tu etwas." };
|
||||
|
||||
[Fact]
|
||||
public async Task Anlegen_erzeugt_Datei_und_DB_Zeile_in_einem_Zug()
|
||||
{
|
||||
var created = await _board.CreateAsync(Def("NVDA recherchieren"), default);
|
||||
|
||||
created.Id.ShouldNotBeNullOrWhiteSpace();
|
||||
created.Id.ShouldStartWith("t-");
|
||||
|
||||
// DB-Seite
|
||||
(await _repo.GetAsync(created.Id, default)).ShouldNotBeNull();
|
||||
|
||||
// Datei-Seite: existiert und lässt sich zur selben Definition zurücklesen
|
||||
var path = Path.Combine(_tasksDir, created.FileName);
|
||||
File.Exists(path).ShouldBeTrue();
|
||||
|
||||
TaskFrontmatter.TryParse(File.ReadAllText(path), out var fromFile, out _).ShouldBeTrue();
|
||||
fromFile.Id.ShouldBe(created.Id);
|
||||
fromFile.Title.ShouldBe("NVDA recherchieren");
|
||||
fromFile.Assignee.ShouldBe("@new");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Dateiname_ist_beschreibend()
|
||||
{
|
||||
var created = await _board.CreateAsync(Def("NVDA Earnings recherchieren"), default);
|
||||
created.FileName.ShouldStartWith("nvda-earnings-recherchieren-");
|
||||
created.FileName.ShouldEndWith(".md");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aendern_schreibt_Datei_und_DB_fort()
|
||||
{
|
||||
var created = await _board.CreateAsync(Def("Alt"), default);
|
||||
|
||||
var updated = await _board.UpdateAsync(created.Id,
|
||||
c => c with { Title = "Neu", Status = TaskItemStatus.Done }, default);
|
||||
|
||||
updated!.Title.ShouldBe("Neu");
|
||||
updated.Status.ShouldBe(TaskItemStatus.Done);
|
||||
|
||||
// Beide Seiten spiegeln die Änderung
|
||||
(await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
TaskFrontmatter.TryParse(
|
||||
File.ReadAllText(Path.Combine(_tasksDir, created.FileName)), out var fromFile, out _);
|
||||
fromFile.Title.ShouldBe("Neu");
|
||||
fromFile.Status.ShouldBe(TaskItemStatus.Done);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Kommentar_landet_im_Rumpf()
|
||||
{
|
||||
var created = await _board.CreateAsync(Def(), default);
|
||||
|
||||
await _board.AddCommentAsync(created.Id, "agent-a", "Erstes Zwischenergebnis.", default);
|
||||
await _board.AddCommentAsync(created.Id, "agent-b", "Bitte nachbessern.", default);
|
||||
|
||||
var body = (await _repo.GetAsync(created.Id, default))!.Body;
|
||||
body.ShouldContain("Erstes Zwischenergebnis.");
|
||||
body.ShouldContain("agent-b");
|
||||
body.ShouldContain("Bitte nachbessern.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_spiegelt_vorhandene_Dateien_in_die_DB()
|
||||
{
|
||||
Directory.CreateDirectory(_tasksDir);
|
||||
File.WriteAllText(Path.Combine(_tasksDir, "eins.md"),
|
||||
"---\nid: t-eins\ntitle: Eins\nassignee: \"@new\"\n---\nMach eins.");
|
||||
File.WriteAllText(Path.Combine(_tasksDir, "zwei.md"),
|
||||
"---\nid: t-zwei\ntitle: Zwei\nassignee: \"@new\"\n---\nMach zwei.");
|
||||
|
||||
var imported = await _board.ImportAllAsync(default);
|
||||
|
||||
imported.ShouldBe(2);
|
||||
(await _repo.GetAsync("t-eins", default))!.Title.ShouldBe("Eins");
|
||||
(await _repo.GetAsync("t-zwei", default))!.Title.ShouldBe("Zwei");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_von_Hand_angelegte_Datei_ohne_Id_bekommt_eine_und_behaelt_sie()
|
||||
{
|
||||
Directory.CreateDirectory(_tasksDir);
|
||||
var path = Path.Combine(_tasksDir, "handarbeit.md");
|
||||
File.WriteAllText(path, "---\ntitle: Von Hand\nassignee: \"@human\"\n---\nManuell angelegt.");
|
||||
|
||||
var imported = await _board.ImportFileAsync(path, default);
|
||||
|
||||
imported.ShouldNotBeNull();
|
||||
imported!.Id.ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Die Id wurde in die Datei zurückgeschrieben — überlebt den nächsten Start.
|
||||
TaskFrontmatter.TryParse(File.ReadAllText(path), out var fromFile, out _);
|
||||
fromFile.Id.ShouldBe(imported.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_verschwundene_Datei_wird_archiviert_statt_geloescht()
|
||||
{
|
||||
var created = await _board.CreateAsync(Def("Vergänglich"), default);
|
||||
File.Delete(Path.Combine(_tasksDir, created.FileName));
|
||||
|
||||
await _board.ImportAllAsync(default);
|
||||
|
||||
// Die Historie bleibt: die Zeile ist archiviert, nicht weg.
|
||||
var after = await _repo.GetAsync(created.Id, default);
|
||||
after.ShouldNotBeNull();
|
||||
after!.Status.ShouldBe(TaskItemStatus.Archived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Re_Import_setzt_einen_laufenden_Status_nicht_zurueck()
|
||||
{
|
||||
// Die Kernregel der Wahrheitsaufteilung über den Dienst geprüft.
|
||||
var created = await _board.CreateAsync(Def(), default);
|
||||
await _repo.TryClaimAsync(created.Id, "2026-07-31T07:00:00.0000000Z", "tok",
|
||||
DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), default);
|
||||
await _repo.CompleteClaimAsync(created.Id, "tok", TaskItemStatus.Done, DateTime.UtcNow, default);
|
||||
|
||||
// Die Datei sagt weiterhin "todo" (so wurde sie angelegt) — ein Import darf das
|
||||
// nicht über den erreichten Done-Status stülpen.
|
||||
await _board.ImportAllAsync(default);
|
||||
|
||||
(await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Der tool_job-Dispatch — ersetzt den ToolJobScheduler. Ein fälliger Poll-Task tickt einen
|
||||
/// <see cref="IToolJobProvider"/> und weckt den Agenten nur, wenn der Tick etwas meldet.
|
||||
/// </summary>
|
||||
public sealed class ToolJobDispatchTests
|
||||
{
|
||||
private static TaskItem PollTask(string agent = "agent-a") => new()
|
||||
{
|
||||
Id = "tj-1",
|
||||
Title = "Poll",
|
||||
Type = TaskItemType.ToolJob,
|
||||
ToolName = "PollTool",
|
||||
JobTypeId = "poll",
|
||||
Assignee = "@" + agent
|
||||
};
|
||||
|
||||
private static EngineTaskDispatcher Dispatcher(EngineFixture fixture)
|
||||
=> new(fixture.Engine, () => fixture.Agents, "test-instance",
|
||||
fixture.Registry, fixture.StateStore, NullLoggerFactory.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Poll_ohne_Fund_weckt_den_Agenten_nicht()
|
||||
{
|
||||
var tool = new FakeJobTool(ToolJobResult.NoAction("nichts Neues"));
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
fixture.AddAgent("agent-a", "PollTool");
|
||||
|
||||
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
|
||||
|
||||
ok.ShouldBeTrue("ein Poll ohne Fund ist kein Fehler");
|
||||
tool.Ticks.ShouldBe(1);
|
||||
fixture.Client.ReceivedRequests.ShouldBeEmpty("kein Agent-Lauf, wenn nichts zu wecken war");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Poll_mit_Fund_weckt_den_Agenten()
|
||||
{
|
||||
var tool = new FakeJobTool(ToolJobResult.Wake("Neue Nachricht eingetroffen"));
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
fixture.AddAgent("agent-a", "PollTool");
|
||||
fixture.Client.RespondsWithText("verarbeitet");
|
||||
|
||||
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
|
||||
|
||||
ok.ShouldBeTrue();
|
||||
tool.Ticks.ShouldBe(1);
|
||||
fixture.Client.ReceivedRequests.ShouldNotBeEmpty("der Agent wurde geweckt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_unbekanntes_Poll_Tool_meldet_einen_Fehler()
|
||||
{
|
||||
var fixture = new EngineFixture(); // PollTool nicht registriert
|
||||
fixture.AddAgent("agent-a");
|
||||
|
||||
(await Dispatcher(fixture).DispatchAsync(PollTask(), default)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Ein Tool, das zugleich Poll-Provider ist — zählt Ticks, liefert ein festes Ergebnis.</summary>
|
||||
private sealed class FakeJobTool(ToolJobResult result) : IAgentTool, IToolJobProvider
|
||||
{
|
||||
public int Ticks { get; private set; }
|
||||
|
||||
public string Name => "PollTool";
|
||||
public string Description => "Test-Poll";
|
||||
public JsonElement InputSchema { get; } =
|
||||
JsonDocument.Parse("""{ "type": "object" }""").RootElement.Clone();
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
=> Task.FromResult(ToolResult.Ok("ok"));
|
||||
|
||||
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() => [];
|
||||
|
||||
public Task<ToolJobResult> ExecuteJobAsync(
|
||||
string jobTypeId, IReadOnlyDictionary<string, object?> toolConfig, IStateStore stateStore,
|
||||
ILogger logger, CancellationToken ct, string? agentId = null, string? workspacePath = null)
|
||||
{
|
||||
Ticks++;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user