2 Commits
Author SHA1 Message Date
RichardandClaude Opus 5.5 ab1bd67464 Merge feat/688-department-situation: Lage je Abteilung (#688)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:58:13 +02:00
RichardandClaude Opus 5.5 f649f2fd7a Organigramm: Lage je Abteilung in der Teamuebersicht (#688)
- TeamMember: Laeufe und Kosten heute, offene und blockierte Aufgaben je Agent
  (eine Abfrage fuer alle, nicht eine je Agent)
- TeamUnit: dieselben Werte samt Untereinheiten, offene Vorgaenge aus der Einheit,
  heutige Kostenmeldung (#699)
- team.js: Lagezeile auf jeder Karte und in der Seitenleiste, Instanz summiert;
  Spruenge zu Kosten je Einheit und zu den Vorgaengen; i18n en/de
- Tests: TeamOverviewTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:50:17 +02:00
10 changed files with 207 additions and 15 deletions
+8
View File
@@ -1586,6 +1586,14 @@ Pixelgrafik: charmant, aber mit wenig Information. Die Verbindung aus aufklappba
Organisation, Live-Zuständen und nach oben gereichten Problemen mit Direktzugriff hat Organisation, Live-Zuständen und nach oben gereichten Problemen mit Direktzugriff hat
keines davon. keines davon.
> **Umgesetzt (#688) als Ausbau dieser Ansicht, nicht als zweite.** Jede Einheitskarte der
> Teamübersicht trägt die Lage der Abteilung samt allem darunter: Läufe und Kosten heute
> (mit Warnzeichen, wenn #699 heute eine Kostenmeldung für sie erhoben hat), offene und
> blockierte Aufgaben, offene Vorgänge aus der Einheit (#689). Die Instanzkarte summiert
> dieselben Werte über alle. Die Seitenleiste führt von dort in die Kosten je Einheit und
> die Vorgänge. **Offen:** dieselbe Ansicht in der Avalonia-Oberfläche — dort gibt es die
> Teamübersicht noch gar nicht.
--- ---
## 11 — Reihenfolge ## 11 — Reihenfolge
+3 -1
View File
@@ -146,7 +146,9 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary> /// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents, public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents,
organisation: () => Core.Organisation.OrgChart.From(Instance)); organisation: () => Core.Organisation.OrgChart.From(Instance),
board: Board, cases: Cases,
costAlerts: CostWatch is { } watch ? ct => watch.RecentAsync(1, ct) : null);
public TelegramClientManager? TelegramClient { get; private init; } public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; } public OpenRouterStatusService? Status { get; private init; }
+47 -6
View File
@@ -5,6 +5,7 @@ using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging; using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.App.Services; namespace ClawdDotNet.App.Services;
@@ -43,7 +44,12 @@ public sealed record TeamMember(
string? LastRunResult, string? LastRunResult,
long TypicalRunMs, long TypicalRunMs,
string? UnitId = null, string? UnitId = null,
bool IsLead = false); bool IsLead = false,
// #688: the agent's day — what a department's situation is added up from.
int RunsToday = 0,
decimal CostToday = 0,
int OpenTasks = 0,
int BlockedTasks = 0);
/// <summary> /// <summary>
/// One unit in the team overview (#711, stage 1), with what happens inside it rolled up — /// One unit in the team overview (#711, stage 1), with what happens inside it rolled up —
@@ -60,7 +66,15 @@ public sealed record TeamUnit(
int Agents, int Agents,
IReadOnlyDictionary<TeamState, int> States, IReadOnlyDictionary<TeamState, int> States,
int PendingApprovals, int PendingApprovals,
TeamState Worst); TeamState Worst,
// #688: the department's situation, not the agent's — runs and cost today, tasks open and
// blocked, cases raised inside it, and the cost alert of today, if any (#699).
int RunsToday = 0,
decimal CostToday = 0,
int OpenTasks = 0,
int BlockedTasks = 0,
int OpenCases = 0,
string? CostAlert = null);
/// <summary>The whole picture at one moment: every agent and unit, and who reached whom recently.</summary> /// <summary>The whole picture at one moment: every agent and unit, and who reached whom recently.</summary>
public sealed record TeamSnapshot( public sealed record TeamSnapshot(
@@ -89,7 +103,10 @@ public sealed class TeamOverview(
IUsageRepository? usage, IUsageRepository? usage,
Func<IReadOnlyList<AgentConfig>> agents, Func<IReadOnlyList<AgentConfig>> agents,
TimeProvider? clock = null, TimeProvider? clock = null,
Func<OrgChart>? organisation = null) Func<OrgChart>? organisation = null,
TaskboardService? board = null,
Core.Cases.ICaseRepository? cases = null,
Func<CancellationToken, Task<IReadOnlyList<Core.Accounting.RaisedCostAlert>>>? costAlerts = null)
{ {
/// <summary>How far back the last receipt of each agent is looked for.</summary> /// <summary>How far back the last receipt of each agent is looked for.</summary>
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3); public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
@@ -140,6 +157,14 @@ public sealed class TeamOverview(
var org = organisation?.Invoke() ?? new OrgChart([], configured); var org = organisation?.Invoke() ?? new OrgChart([], configured);
// #688: the day of each agent, in one query each — not one per agent.
var today = DateOnly.FromDateTime(_clock.GetLocalNow().DateTime);
var usageToday = usage is null ? [] : await usage.GetDailyByAgentAsync(today, today, ct);
var openTasks = board is null ? [] : await board.ListAsync(new TaskQuery { Status = TaskItemStatus.Todo, Limit = 5_000 }, ct);
var blockedTasks = board is null ? [] : await board.ListAsync(new TaskQuery { Status = TaskItemStatus.Blocked, Limit = 5_000 }, ct);
int Count(IReadOnlyList<TaskItem> tasks, string agentId)
=> tasks.Count(t => string.Equals(TaskAssignee.AgentId(t.Assignee), agentId, StringComparison.OrdinalIgnoreCase));
var members = new List<TeamMember>(); var members = new List<TeamMember>();
foreach (var agent in configured) foreach (var agent in configured)
{ {
@@ -155,7 +180,10 @@ public sealed class TeamOverview(
members.Add(new TeamMember( members.Add(new TeamMember(
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals, agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds, last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds,
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null)); org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null,
usageToday.Where(u => string.Equals(u.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase)).Sum(u => u.Runs),
usageToday.Where(u => string.Equals(u.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase)).Sum(u => u.CostUsd),
Count(openTasks, agent.AgentId), Count(blockedTasks, agent.AgentId)));
} }
// #720: files where agents keep getting in each other's way — for a human to decide // #720: files where agents keep getting in each other's way — for a human to decide
@@ -165,7 +193,19 @@ public sealed class TeamOverview(
.Select(h => h with { Path = Readable(h.Path, configured) }).ToList() .Select(h => h with { Path = Readable(h.Path, configured) }).ToList()
: []; : [];
return new TeamSnapshot(members, links, now, RollUp(org, members), hotspots); var openCases = cases is null ? [] : await cases.ListOpenAsync(ct);
var alerts = costAlerts is null ? [] : (await costAlerts(ct)).Where(a => a.Period == today.ToString("yyyy-MM-dd")).ToList();
var units = RollUp(org, members)
.Select(u => u with
{
OpenCases = openCases.Count(c => c.OpenedUnitId is { } id
&& (string.Equals(id, u.Id, StringComparison.OrdinalIgnoreCase)
|| org.Ancestors(id).Any(a => string.Equals(a.Id, u.Id, StringComparison.OrdinalIgnoreCase)))),
CostAlert = alerts.FirstOrDefault(a => string.Equals(a.Scope, u.Id, StringComparison.OrdinalIgnoreCase))?.Kind.ToString()
})
.ToList();
return new TeamSnapshot(members, links, now, units, hotspots);
} }
/// <summary> /// <summary>
@@ -222,7 +262,8 @@ public sealed class TeamOverview(
result.Add(new TeamUnit( result.Add(new TeamUnit(
unit.Id, unit.Name, unit.ParentId, org.PathOf(unit.Id), org.LeaderOf(unit.Id)?.AgentId, unit.Goal, unit.Id, unit.Name, unit.ParentId, org.PathOf(unit.Id), org.LeaderOf(unit.Id)?.AgentId, unit.Goal,
inside.Count, states, inside.Sum(m => m.PendingApprovals), worst)); inside.Count, states, inside.Sum(m => m.PendingApprovals), worst,
inside.Sum(m => m.RunsToday), inside.Sum(m => m.CostToday), inside.Sum(m => m.OpenTasks), inside.Sum(m => m.BlockedTasks)));
} }
return result; return result;
} }
@@ -2332,6 +2332,21 @@ td.klein {
margin-top: 8px; margin-top: 8px;
} }
/* #688: a department's situation — runs, cost, tasks, cases */
.team-lage {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
margin-top: 6px;
font-size: 12px;
color: var(--gedaempft);
font-variant-numeric: tabular-nums;
}
.team-lage .ton-warnung {
color: var(--warnung);
}
.team-chip { .team-chip {
padding: 1px 6px; padding: 1px 6px;
font-size: 11px; font-size: 11px;
@@ -203,6 +203,13 @@
"team.members": "Mitglieder", "team.members": "Mitglieder",
"team.subUnits": "Untereinheiten", "team.subUnits": "Untereinheiten",
"team.openOrganisation": "Organisation bearbeiten", "team.openOrganisation": "Organisation bearbeiten",
"team.openCosts": "Kosten je Einheit",
"team.openCases": "Offene Vorgänge",
"team.situation.runsHint": "Läufe heute, in dieser Einheit und darunter",
"team.situation.costHint": "Kosten heute, in dieser Einheit und darunter",
"team.situation.tasksHint": "Offene Aufgaben",
"team.situation.blockedHint": "Blockierte Aufgaben — warten auf jemanden",
"team.situation.casesHint": "Offene Vorgänge aus dieser Einheit",
"team.rootSubtitle": "Instanz · oberste Ebene", "team.rootSubtitle": "Instanz · oberste Ebene",
"team.structure": "Struktur", "team.structure": "Struktur",
"team.searchPlaceholder": "Agenten, Einheiten, Modelle suchen…", "team.searchPlaceholder": "Agenten, Einheiten, Modelle suchen…",
@@ -203,6 +203,13 @@
"team.members": "Members", "team.members": "Members",
"team.subUnits": "Sub-units", "team.subUnits": "Sub-units",
"team.openOrganisation": "Edit organisation", "team.openOrganisation": "Edit organisation",
"team.openCosts": "Costs by unit",
"team.openCases": "Open cases",
"team.situation.runsHint": "Runs today, in this unit and below",
"team.situation.costHint": "Cost today, in this unit and below",
"team.situation.tasksHint": "Open tasks",
"team.situation.blockedHint": "Blocked tasks — waiting for someone",
"team.situation.casesHint": "Open cases raised in this unit",
"team.rootSubtitle": "Instance · top level", "team.rootSubtitle": "Instance · top level",
"team.structure": "Structure", "team.structure": "Structure",
"team.searchPlaceholder": "Search agents, units, models…", "team.searchPlaceholder": "Search agents, units, models…",
@@ -235,7 +235,8 @@ function indexSnapshot() {
agents: snapshot.members.length, agents: snapshot.members.length,
states, states,
pendingApprovals: snapshot.members.reduce((sum, m) => sum + m.pendingApprovals, 0), pendingApprovals: snapshot.members.reduce((sum, m) => sum + m.pendingApprovals, 0),
worst worst,
situation: sumSituation(snapshot.members)
}); });
for (const unit of snapshot.units ?? []) { for (const unit of snapshot.units ?? []) {
@@ -250,7 +251,15 @@ function indexSnapshot() {
agents: unit.agents, agents: unit.agents,
states: unit.states ?? {}, states: unit.states ?? {},
pendingApprovals: unit.pendingApprovals, pendingApprovals: unit.pendingApprovals,
worst: unit.worst worst: unit.worst,
situation: {
runsToday: unit.runsToday ?? 0,
costToday: unit.costToday ?? 0,
openTasks: unit.openTasks ?? 0,
blockedTasks: unit.blockedTasks ?? 0,
openCases: unit.openCases ?? 0,
costAlert: unit.costAlert ?? null
}
}); });
} }
} }
@@ -727,6 +736,33 @@ function visibleMembers(card) {
return { rows, hidden: all.length - rows.length }; return { rows, hidden: all.length - rows.length };
} }
/** #688: The instance's own situation — the units' come from the instance, this total is added up here. */
function sumSituation(members) {
const sum = (key) => members.reduce((n, m) => n + (m[key] ?? 0), 0);
return { runsToday: sum("runsToday"), costToday: sum("costToday"), openTasks: sum("openTasks"), blockedTasks: sum("blockedTasks"), openCases: 0, costAlert: null };
}
/**
* #688: A department's situation in one line — runs and cost today, tasks open and blocked,
* cases raised in it. The way in from the top: first the unit that stands out, then the agent.
*/
function situationLine(card) {
const s = card.situation;
if (!s) return null;
const parts = [
erstelleElement("span", { title: t("team.situation.runsHint"), text: `▶ ${s.runsToday}` }),
erstelleElement("span", {
klasse: s.costAlert ? "ton-warnung" : "",
title: s.costAlert ? t(`usage.units.kind.${s.costAlert}`) : t("team.situation.costHint"),
text: `${s.costAlert ? "⚠ " : ""}${Number(s.costToday).toFixed(2)} $`
}),
erstelleElement("span", { title: t("team.situation.tasksHint"), text: `☐ ${s.openTasks}` })
];
if (s.blockedTasks) parts.push(erstelleElement("span", { klasse: "ton-warnung", title: t("team.situation.blockedHint"), text: `⛔ ${s.blockedTasks}` }));
if (s.openCases) parts.push(erstelleElement("span", { klasse: "ton-warnung", title: t("team.situation.casesHint"), text: `⚖ ${s.openCases}` }));
return erstelleElement("div", { klasse: "team-lage" }, ...parts);
}
function buildCard(card) { function buildCard(card) {
const worst = STATES[card.worst] ?? STATES.Idle; const worst = STATES[card.worst] ?? STATES.Idle;
const dimmed = hits && !hits.onPath.has(card.id); const dimmed = hits && !hits.onPath.has(card.id);
@@ -754,7 +790,8 @@ function buildCard(card) {
erstelleElement("span", { klasse: "team-marker", text: worst.symbol }), erstelleElement("span", { klasse: "team-marker", text: worst.symbol }),
erstelleElement("span", { klasse: "team-karte-name", text: card.title })), erstelleElement("span", { klasse: "team-karte-name", text: card.title })),
erstelleElement("div", { klasse: "team-karte-unter", text: card.subtitle }), erstelleElement("div", { klasse: "team-karte-unter", text: card.subtitle }),
erstelleElement("div", { klasse: "team-chips" }, ...chips(card)))); erstelleElement("div", { klasse: "team-chips" }, ...chips(card)),
situationLine(card)));
const { rows, hidden } = visibleMembers(card); const { rows, hidden } = visibleMembers(card);
if (rows.length || hidden) { if (rows.length || hidden) {
@@ -1222,8 +1259,9 @@ function cardRows(cardId) {
panelHead(card.title), panelHead(card.title),
erstelleElement("p", { klasse: "gedaempft text-klein", text: card.unit?.path ?? card.subtitle }), erstelleElement("p", { klasse: "gedaempft text-klein", text: card.unit?.path ?? card.subtitle }),
erstelleElement("div", { klasse: "team-chips" }, ...chips(card)), erstelleElement("div", { klasse: "team-chips" }, ...chips(card)),
situationLine(card),
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("team.subtreeHint") }) erstelleElement("p", { klasse: "gedaempft text-klein", text: t("team.subtreeHint") })
]; ].filter(Boolean);
if (card.unit?.goal) rows.push(erstelleElement("p", { klasse: "text-klein", text: card.unit.goal })); if (card.unit?.goal) rows.push(erstelleElement("p", { klasse: "text-klein", text: card.unit.goal }));
@@ -1263,6 +1301,8 @@ function cardRows(cardId) {
})); }));
} }
if (card.unit) actions.append(link("#/organisation", t("team.openOrganisation"))); if (card.unit) actions.append(link("#/organisation", t("team.openOrganisation")));
if (card.situation?.costAlert || card.situation?.costToday > 0) actions.append(link("#/verbrauch", t("team.openCosts")));
if (card.situation?.openCases) actions.append(link("#/cases", t("team.openCases")));
if (actions.children.length) rows.push(actions); if (actions.children.length) rows.push(actions);
return rows; return rows;
} }
+14 -2
View File
@@ -212,7 +212,14 @@ public sealed record TeamUnitDto(
int Agents, int Agents,
IReadOnlyDictionary<string, int> States, IReadOnlyDictionary<string, int> States,
int PendingApprovals, int PendingApprovals,
string Worst); string Worst,
// #688: the department's situation.
int RunsToday = 0,
decimal CostToday = 0,
int OpenTasks = 0,
int BlockedTasks = 0,
int OpenCases = 0,
string? CostAlert = null);
/// <summary> /// <summary>
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck, /// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
@@ -230,7 +237,12 @@ public sealed record TeamMemberDto(
string? LastRunResult, string? LastRunResult,
long TypicalRunMs, long TypicalRunMs,
string? UnitId, string? UnitId,
bool IsLead); bool IsLead,
// #688: the agent's day.
int RunsToday = 0,
decimal CostToday = 0,
int OpenTasks = 0,
int BlockedTasks = 0);
/// <summary>#711: A run in progress.</summary> /// <summary>#711: A run in progress.</summary>
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc); public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
+3 -2
View File
@@ -95,13 +95,14 @@ internal static class Mapping
m.AgentId, m.DisplayName, m.Model, m.State.ToString(), m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
m.Runs.Select(ToDto).ToList(), m.Runs.Select(ToDto).ToList(),
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs, m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs,
m.UnitId, m.IsLead)).ToList(), m.UnitId, m.IsLead, m.RunsToday, m.CostToday, m.OpenTasks, m.BlockedTasks)).ToList(),
snapshot.RecentLinks.Select(ToDto).ToList(), snapshot.RecentLinks.Select(ToDto).ToList(),
snapshot.AtUtc, snapshot.AtUtc,
(snapshot.Units ?? []).Select(u => new TeamUnitDto( (snapshot.Units ?? []).Select(u => new TeamUnitDto(
u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents, u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents,
u.States.ToDictionary(s => s.Key.ToString(), s => s.Value), u.States.ToDictionary(s => s.Key.ToString(), s => s.Value),
u.PendingApprovals, u.Worst.ToString())).ToList(), u.PendingApprovals, u.Worst.ToString(),
u.RunsToday, u.CostToday, u.OpenTasks, u.BlockedTasks, u.OpenCases, u.CostAlert)).ToList(),
(snapshot.ConflictHotspots ?? []).Select(h => new ConflictHotspotDto( (snapshot.ConflictHotspots ?? []).Select(h => new ConflictHotspotDto(
h.Workspace, h.Path, h.Conflicts, h.LastAtUtc, h.Agents)).ToList()); h.Workspace, h.Path, h.Conflicts, h.LastAtUtc, h.Agents)).ToList());
@@ -186,4 +186,63 @@ public sealed class TeamOverviewTests
TeamOverview.Readable(elsewhere, configured).ShouldBe(elsewhere); TeamOverview.Readable(elsewhere, configured).ShouldBe(elsewhere);
TeamOverview.Readable(elsewhere, []).ShouldBe(elsewhere); TeamOverview.Readable(elsewhere, []).ShouldBe(elsewhere);
} }
// ═══════════════════ the department's situation (#688) ═══════════════════
[Fact]
public void A_departments_day_is_added_up_along_the_tree()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("marketing-lead", "marketing", TeamState.Idle) with { RunsToday = 2, CostToday = 0.5m, OpenTasks = 1 },
Member("ads", "amazon", TeamState.Idle) with { RunsToday = 5, CostToday = 1.25m, OpenTasks = 3, BlockedTasks = 1 }
]);
var marketing = units.Single(u => u.Id == "marketing");
marketing.RunsToday.ShouldBe(7);
marketing.CostToday.ShouldBe(1.75m);
marketing.OpenTasks.ShouldBe(4);
marketing.BlockedTasks.ShouldBe(1);
units.Single(u => u.Id == "amazon").CostToday.ShouldBe(1.25m);
}
[Fact]
public async Task The_snapshot_carries_runs_cost_tasks_cases_and_the_cost_alert_of_today()
{
var directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "team-" + Guid.NewGuid().ToString("N")[..8]);
try
{
var storage = new Core.Storage.SqliteStorage(Path.Combine(directory, "state.db"));
var usage = new Core.Accounting.SqliteUsageRepository(storage);
var board = new Core.Tasks.TaskboardService(new Core.Tasks.SqliteTaskRepository(storage), Path.Combine(directory, "tasks"));
var cases = new Core.Cases.SqliteCaseRepository(storage);
await usage.RecordAsync(new Core.Accounting.RunUsage { AgentId = "ads", Model = "m", CostUsd = 0.4m, CostIsKnown = true, Status = "Completed", OccurredAt = DateTime.Now }, default);
await board.CreateAsync(new Core.Tasks.TaskItem { Title = "Bid for spring", Assignee = "@ads" }, default);
await board.CreateAsync(new Core.Tasks.TaskItem { Title = "Waiting", Assignee = "@ads", Status = Core.Tasks.TaskItemStatus.Blocked }, default);
await cases.SaveAsync(new Core.Cases.CaseRecord { Id = "c-1", Subject = "Field", OpenedBy = "ads", OpenedUnitId = "amazon", OpenedAt = DateTime.UtcNow }, default);
var today = DateOnly.FromDateTime(DateTime.Now).ToString("yyyy-MM-dd");
var overview = new TeamOverview(null, null, null, usage, () => Shop().Agents.ToList(),
organisation: Shop, board: board, cases: cases,
costAlerts: _ => Task.FromResult<IReadOnlyList<Core.Accounting.RaisedCostAlert>>(
[new Core.Accounting.RaisedCostAlert(today, "amazon", Core.Accounting.CostAlertKind.Deviation, "x", DateTime.UtcNow)]));
var snapshot = await overview.SnapshotAsync(default);
var ads = snapshot.Members.Single(m => m.AgentId == "ads");
ads.RunsToday.ShouldBe(1);
ads.CostToday.ShouldBe(0.4m);
ads.OpenTasks.ShouldBe(1);
ads.BlockedTasks.ShouldBe(1);
var marketing = snapshot.Units!.Single(u => u.Id == "marketing");
marketing.OpenCases.ShouldBe(1, "a case raised in amazon lies inside marketing");
snapshot.Units!.Single(u => u.Id == "amazon").CostAlert.ShouldBe("Deviation");
marketing.CostAlert.ShouldBeNull("the alert was raised on amazon, not on marketing");
}
finally
{
Core.Storage.SqliteStorage.ReleasePooledConnections(directory);
try { Directory.Delete(directory, recursive: true); } catch { /* cleanup is secondary */ }
}
}
} }