Entscheidungen aus dem Testbetrieb umgesetzt (#572): #577 #579 #581 #589 #193 #205 #594 #595 #597 #598 #599 #588

- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete),
  gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten,
  Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage
- Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig,
  Selbstbearbeitung gesperrt (allowSelfEdit)
- Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf,
  Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt
- Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab,
  Token nicht mehr ins systemd-Journal
Version 0.1.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-15 11:23:10 +02:00
co-authored by Claude Opus 5
parent 8ab9d99abe
commit 0b6ea73ec1
42 changed files with 2646 additions and 512 deletions
+41 -26
View File
@@ -83,31 +83,6 @@ public sealed record ChatEntryDto(
/// <summary>Was der Browser schickt, wenn jemand etwas in den Chat tippt.</summary>
public sealed record SendMessageDto(string Message);
/// <summary>Ein geplanter Lauf.</summary>
public sealed record JobDto(
string Kind,
string JobId,
string AgentId,
string AgentName,
string ToolName,
string Cron,
string Message,
bool RunOnStart,
DateTime? NextRunUtc,
DateTime? LastRunLocal,
bool CronValid,
bool Enabled);
/// <summary>Was einen geplanten Lauf ausmacht — beim Anlegen wie beim Ändern.</summary>
public sealed record JobInputDto(
string AgentId,
string Kind,
string ToolName = "",
string JobTypeId = "",
string Cron = "",
string TaskMessage = "",
bool RunOnStart = false);
/// <summary>Ein Dienst der Instanz.</summary>
public sealed record ServiceDto(
string ServiceId,
@@ -138,7 +113,47 @@ public sealed record TaskDto(
string Body,
DateTime CreatedAt,
DateTime UpdatedAt,
TaskWhenDto? When = null);
TaskWhenDto? When = null,
DateTime? NextRunUtc = null,
TaskLastRunDto? LastRun = null,
string? Hinweis = null);
/// <summary>Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten.</summary>
public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef);
/// <summary>Was die Weboberflaeche beim Anlegen oder Aendern einer Aufgabe schickt (#594).</summary>
public sealed record TaskInputDto(
string Title,
string Body = "",
string Assignee = "",
string? WhenKind = null,
string? WhenValue = null,
string? TimeZone = null,
string Acceptance = "",
int Priority = 3);
/// <summary>Die Kostenprognose einer Instanz (#595).</summary>
public sealed record ForecastDto(
IReadOnlyList<ForecastTaskDto> Tasks,
decimal TasksPerDay,
decimal OtherPerDay,
decimal TotalPerDay,
decimal TotalPerMonth,
decimal OneOffOpen,
int WithoutBasis);
/// <summary>Eine Zeile der Kostenprognose. <c>Basis</c>: Laeufe, Agent, Schaetzung oder Unbekannt.</summary>
public sealed record ForecastTaskDto(
string TaskId,
string Title,
string AgentId,
string Model,
double RunsPerDay,
bool OneOffOpen,
decimal? CostPerRun,
string Basis,
int Sample,
decimal CostPerDay);
/// <summary>
/// Der Termin einer Aufgabe (<c>cron</c>, <c>every</c>, <c>at</c>) samt Zeitzone. Ohne ihn
+30 -24
View File
@@ -69,32 +69,38 @@ internal static class Mapping
public static ChatEntryDto ToDto(this ChatEntry entry)
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
public static JobDto ToDto(this JobSummary job, DateTime? lastRun)
=> new(
job.Kind == JobKind.ToolJob ? "toolJob" : "agentWakeup",
job.JobId,
job.AgentId,
job.AgentName,
job.ToolName,
job.Cron,
job.Message,
job.RunOnStart,
job.NextRunUtc,
lastRun,
job.CronValid,
job.Enabled);
public static TaskDto ToDto(this TaskOverview overview)
=> overview.Task.ToDto() with
{
NextRunUtc = overview.NextRunUtc,
LastRun = overview.LastRun is { } lauf
? new TaskLastRunDto(lauf.OccurredAt, lauf.Status, lauf.CostUsd, lauf.CostIsKnown, lauf.ResultRef)
: null,
Hinweis = overview.Hinweis
};
public static JobDefinition ToDefinition(this JobInputDto input)
public static TaskInput ToInput(this TaskInputDto input)
=> new(
input.AgentId,
string.Equals(input.Kind, "toolJob", StringComparison.OrdinalIgnoreCase)
? JobKind.ToolJob
: JobKind.AgentWakeup,
input.ToolName,
input.JobTypeId,
input.Cron,
input.TaskMessage,
input.RunOnStart);
input.Title ?? "",
input.Body ?? "",
input.Assignee ?? "",
input.WhenKind,
input.WhenValue,
input.TimeZone,
input.Acceptance ?? "",
input.Priority);
public static ForecastDto ToDto(this InstanzPrognose prognose)
=> new(
prognose.Aufgaben.Select(a => new ForecastTaskDto(
a.TaskId, a.Title, a.AgentId, a.Model, a.LaeufeProTag, a.EinmaligOffen,
a.KostenJeLauf, a.Grundlage.ToString(), a.Stichprobe, a.KostenProTag)).ToList(),
prognose.AufgabenProTag,
prognose.SonstigeProTag,
prognose.SummeProTag,
prognose.SummeProMonat,
prognose.EinmaligOffen,
prognose.OhneKostenbasis);
public static ServiceDto ToDto(this ServiceConfig service)
=> new(
+21 -47
View File
@@ -25,7 +25,6 @@ internal static class ApiEndpoints
api.MapInfo();
api.MapAgents();
api.MapChat();
api.MapJobs();
api.MapServices();
api.MapTasks();
api.MapStaging();
@@ -207,44 +206,6 @@ internal static class ApiEndpoints
});
}
// ─── Geplante Läufe ───
private static void MapJobs(this RouteGroupBuilder api)
{
var jobs = api.MapGroup("/jobs");
jobs.MapGet("/", (AppHost host) => host.JobAdmin.List()
.Select(j => j.ToDto(host.Runs.LastRun(j.JobId)))
.ToList());
jobs.MapPost("/", (AppHost host, JobInputDto input) =>
host.JobAdmin.Add(input.ToDefinition()).ToResponse());
jobs.MapPut("/{jobId}", (AppHost host, string jobId, JobInputDto input) =>
host.JobAdmin.Update(jobId, input.ToDefinition()).ToResponse());
jobs.MapDelete("/{jobId}", (AppHost host, string jobId, string agentId, string kind) =>
host.JobAdmin.Remove(
agentId,
jobId,
string.Equals(kind, "toolJob", StringComparison.OrdinalIgnoreCase)
? JobKind.ToolJob
: JobKind.AgentWakeup).ToResponse());
jobs.MapPut("/{jobId}/enabled", (
AppHost host, string jobId, string agentId, bool enabled) =>
host.JobAdmin.SetEnabled(agentId, jobId, enabled).ToResponse());
jobs.MapPost("/{jobId}/run", async (
AppHost host, string jobId, CancellationToken ct) =>
{
if (host.JobAdmin.List().FirstOrDefault(j => j.JobId == jobId) is not { } job)
return Results.NotFound(new OutcomeDto(false, $"Kein geplanter Lauf '{jobId}'."));
return (await host.Runs.RunAsync(job, ct)).ToResponse();
});
}
// ─── Dienste ───
private static void MapServices(this RouteGroupBuilder api)
@@ -272,17 +233,24 @@ internal static class ApiEndpoints
{
var tasks = api.MapGroup("/tasks");
// #594: Das Taskboard ist die einzige Stelle fuer geplante Arbeit. Die frueheren
// /api/jobs bearbeiteten die Alt-Konfiguration, die nach der Uebernahme nichts mehr bewirkte.
tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) =>
{
if (host.Board is not { } board)
return Unavailable(
"Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.");
await host.TaskAdmin.ListAsync(limit, ct) is { } list
? Results.Ok(list.Select(t => t.ToDto()).ToList())
: Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich."));
var items = await board.ListAsync(
new Core.Tasks.TaskQuery { Limit = Math.Clamp(limit, 1, 500) }, ct);
tasks.MapPost("/", async (AppHost host, TaskInputDto input, CancellationToken ct) =>
(await host.TaskAdmin.CreateAsync(input.ToInput(), ct)).ToResponse());
return Results.Ok(items.Select(t => t.ToDto()).ToList());
});
tasks.MapPut("/{id}", async (AppHost host, string id, TaskInputDto input, CancellationToken ct) =>
(await host.TaskAdmin.UpdateAsync(id, input.ToInput(), ct)).ToResponse());
tasks.MapPut("/{id}/enabled", async (AppHost host, string id, bool enabled, CancellationToken ct) =>
(await host.TaskAdmin.SetEnabledAsync(id, enabled, ct)).ToResponse());
tasks.MapPost("/{id}/run", async (AppHost host, string id, CancellationToken ct) =>
(await host.TaskAdmin.RunNowAsync(id, ct)).ToResponse());
tasks.MapGet("/{id}", async (AppHost host, string id, CancellationToken ct) =>
{
@@ -344,6 +312,12 @@ internal static class ApiEndpoints
var recent = await usage.GetRecentAsync(Math.Clamp(limit, 1, 1000), ct);
return Results.Ok(recent.Select(r => r.ToDto()).ToList());
});
// #595: Hochrechnung aus Terminen und bisherigen Laeufen.
api.MapGet("/usage/forecast", async (AppHost host, CancellationToken ct) =>
await host.TaskAdmin.ForecastAsync(ct) is { } prognose
? Results.Ok(prognose.ToDto())
: Unavailable("Keine Prognose: In dieser Instanz gibt es kein Taskboard."));
}
// ─── Protokoll ───