Merge feat/711-stufe1: Einheiten im Lagebild, Pool-Fix fuer die Tests (#711)
This commit is contained in:
@@ -114,7 +114,8 @@ public sealed class AppHost : IAsyncDisposable
|
||||
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
|
||||
|
||||
/// <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));
|
||||
|
||||
public TelegramClientManager? TelegramClient { get; private init; }
|
||||
public OpenRouterStatusService? Status { get; private init; }
|
||||
|
||||
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Activity;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
@@ -40,10 +41,33 @@ public sealed record TeamMember(
|
||||
string? LastRunStatus,
|
||||
DateTime? LastRunAtUtc,
|
||||
string? LastRunResult,
|
||||
long TypicalRunMs);
|
||||
long TypicalRunMs,
|
||||
string? UnitId = null,
|
||||
bool IsLead = false);
|
||||
|
||||
/// <summary>The whole picture at one moment: every agent, and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc);
|
||||
/// <summary>
|
||||
/// One unit in the team overview (#711, stage 1), with what happens inside it rolled up —
|
||||
/// its own members and those of every unit below. A collapsed container shows this, so a
|
||||
/// problem three levels down is still visible from the top.
|
||||
/// </summary>
|
||||
public sealed record TeamUnit(
|
||||
string Id,
|
||||
string Name,
|
||||
string? ParentId,
|
||||
string Path,
|
||||
string? LeaderAgentId,
|
||||
string? Goal,
|
||||
int Agents,
|
||||
IReadOnlyDictionary<TeamState, int> States,
|
||||
int PendingApprovals,
|
||||
TeamState Worst);
|
||||
|
||||
/// <summary>The whole picture at one moment: every agent and unit, and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshot(
|
||||
IReadOnlyList<TeamMember> Members,
|
||||
IReadOnlyList<AgentLink> RecentLinks,
|
||||
DateTime AtUtc,
|
||||
IReadOnlyList<TeamUnit>? Units = null);
|
||||
|
||||
/// <summary>
|
||||
/// #711: The team overview — who is working, who reached whom, where something is stuck.
|
||||
@@ -63,7 +87,8 @@ public sealed class TeamOverview(
|
||||
IAuditRepository? audit,
|
||||
IUsageRepository? usage,
|
||||
Func<IReadOnlyList<AgentConfig>> agents,
|
||||
TimeProvider? clock = null)
|
||||
TimeProvider? clock = null,
|
||||
Func<OrgChart>? organisation = null)
|
||||
{
|
||||
/// <summary>How far back the last receipt of each agent is looked for.</summary>
|
||||
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
|
||||
@@ -106,6 +131,8 @@ public sealed class TeamOverview(
|
||||
}
|
||||
}
|
||||
|
||||
var org = organisation?.Invoke() ?? new OrgChart([], configured);
|
||||
|
||||
var members = new List<TeamMember>();
|
||||
foreach (var agent in configured)
|
||||
{
|
||||
@@ -120,10 +147,54 @@ public sealed class TeamOverview(
|
||||
var state = Classify(runs, approvals, last?.Status, typical, now);
|
||||
members.Add(new TeamMember(
|
||||
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));
|
||||
}
|
||||
|
||||
return new TeamSnapshot(members, links, now);
|
||||
return new TeamSnapshot(members, links, now, RollUp(org, members));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How much a state needs someone — the higher, the sooner. A container takes the most
|
||||
/// urgent state of everything inside it, so "somewhere in here" points at the right thing.
|
||||
/// </summary>
|
||||
public static int Urgency(TeamState state) => state switch
|
||||
{
|
||||
TeamState.Stuck => 5,
|
||||
TeamState.Problem => 4,
|
||||
TeamState.WaitingForApproval => 3,
|
||||
TeamState.BudgetExhausted => 2,
|
||||
TeamState.Working => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Every unit with the states of all agents in it and below. Pure, like
|
||||
/// <see cref="Classify"/>: the page draws the numbers, it does not add them up.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<TeamUnit> RollUp(OrgChart org, IReadOnlyList<TeamMember> members)
|
||||
{
|
||||
var result = new List<TeamUnit>();
|
||||
foreach (var unit in org.Units.OrderBy(u => org.PathOf(u.Id), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
// Everyone whose unit is this one or lies below it. Ancestors() stops at a
|
||||
// cycle, so a broken tree cannot hang the overview.
|
||||
var inside = members
|
||||
.Where(m => m.UnitId is { } id
|
||||
&& (string.Equals(id, unit.Id, StringComparison.OrdinalIgnoreCase)
|
||||
|| org.Ancestors(id).Any(a => string.Equals(a.Id, unit.Id, StringComparison.OrdinalIgnoreCase))))
|
||||
.ToList();
|
||||
|
||||
var states = inside
|
||||
.GroupBy(m => m.State)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
var worst = inside.Select(m => m.State).DefaultIfEmpty(TeamState.Idle).MaxBy(Urgency);
|
||||
|
||||
result.Add(new TeamUnit(
|
||||
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));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -157,7 +157,10 @@ public sealed class BackupService
|
||||
var connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = sourcePath,
|
||||
Mode = SqliteOpenMode.ReadOnly
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
// Eine einmalige Kopie: Aus dem Pool heraus hielte die Verbindung die Datei
|
||||
// danach offen, und unter Windows ließe sie sich nicht mehr ersetzen oder löschen.
|
||||
Pooling = false
|
||||
}.ToString();
|
||||
|
||||
using var conn = new SqliteConnection(connectionString);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
@@ -19,6 +20,9 @@ public sealed class SqliteStorage
|
||||
private readonly string _connectionString;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
|
||||
/// <summary>Every database opened in this process: full path → connection string.</summary>
|
||||
private static readonly ConcurrentDictionary<string, string> OpenedDatabases = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string DatabasePath { get; }
|
||||
|
||||
public SqliteStorage(string databasePath)
|
||||
@@ -37,6 +41,7 @@ public sealed class SqliteStorage
|
||||
Pooling = true,
|
||||
DefaultTimeout = 30
|
||||
}.ToString();
|
||||
OpenedDatabases[Path.GetFullPath(databasePath)] = _connectionString;
|
||||
|
||||
Initialize();
|
||||
|
||||
@@ -51,6 +56,33 @@ public sealed class SqliteStorage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the pooled connections of the databases at <paramref name="path"/> — a
|
||||
/// database file, or a directory and everything below it — so the files can be deleted
|
||||
/// or replaced.
|
||||
///
|
||||
/// <para>Not <see cref="SqliteConnection.ClearAllPools"/>: that is process-wide and also
|
||||
/// takes the connections of every other database in use. The tests used it to clean up
|
||||
/// their temporary databases, and a test running in parallel then lost its connection
|
||||
/// while opening it — an ObjectDisposedException in <c>ApplyPragmas</c>, one in every
|
||||
/// few full runs, always in a different test.</para>
|
||||
/// </summary>
|
||||
public static void ReleasePooledConnections(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
var below = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
|
||||
foreach (var (database, connectionString) in OpenedDatabases)
|
||||
{
|
||||
if (!string.Equals(database, full, StringComparison.OrdinalIgnoreCase)
|
||||
&& !database.StartsWith(below, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
using var connection = new SqliteConnection(connectionString);
|
||||
SqliteConnection.ClearPool(connection);
|
||||
}
|
||||
}
|
||||
|
||||
public SqliteConnection OpenConnection()
|
||||
{
|
||||
var connection = new SqliteConnection(_connectionString);
|
||||
|
||||
@@ -1791,6 +1791,12 @@ td.klein {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.team-eintrag {
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.team-flaeche { flex-direction: column; }
|
||||
.team-seitenleiste { width: auto; }
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
|
||||
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
|
||||
"team.title": "Teamübersicht",
|
||||
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
|
||||
"team.subtitle": "Wer arbeitet, wer hat wen in den letzten Sekunden erreicht und wo klemmt es. Einheiten zeigen, was in ihnen passiert; ein Klick öffnet sie oder zeigt Einzelheiten.",
|
||||
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
|
||||
"team.empty": "Diese Instanz hat keine Agenten.",
|
||||
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
|
||||
@@ -148,6 +148,17 @@
|
||||
"team.openApprovals": "Freigaben öffnen",
|
||||
"team.openChat": "Chat öffnen",
|
||||
"team.openRuns": "Läufe anzeigen",
|
||||
"team.unitAgentsOne": "1 Agent",
|
||||
"team.unitAgents": "{n} Agenten",
|
||||
"team.legendLead": "Leitung der Einheit",
|
||||
"team.legendUnit": "Einheit, Klick öffnet sie",
|
||||
"team.leads": "Leitet",
|
||||
"team.memberOf": "Mitglied von",
|
||||
"team.members": "Mitglieder",
|
||||
"team.subUnits": "Untereinheiten",
|
||||
"team.collapse": "Einheit zuklappen",
|
||||
"team.expand": "Einheit aufklappen",
|
||||
"team.openOrganisation": "Organisation bearbeiten",
|
||||
"nav.services": "Dienste",
|
||||
"nav.group.analytics": "Analyse",
|
||||
"nav.usage": "Tokennutzung",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"org.toolsLostConfirm": "Remove the tools and apply",
|
||||
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
|
||||
"team.title": "Team overview",
|
||||
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
|
||||
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Units show what happens inside them; click to open them or to see details.",
|
||||
"team.noInstance": "No instance selected. Choose an active instance in the header.",
|
||||
"team.empty": "No agents in this instance.",
|
||||
"team.legendLink": "reached (fades after 30 s)",
|
||||
@@ -148,6 +148,17 @@
|
||||
"team.openApprovals": "Open approvals",
|
||||
"team.openChat": "Open chat",
|
||||
"team.openRuns": "Show runs",
|
||||
"team.unitAgentsOne": "1 agent",
|
||||
"team.unitAgents": "{n} agents",
|
||||
"team.legendLead": "lead of the unit",
|
||||
"team.legendUnit": "unit, click to open",
|
||||
"team.leads": "Leads",
|
||||
"team.memberOf": "Member of",
|
||||
"team.members": "Members",
|
||||
"team.subUnits": "Sub-units",
|
||||
"team.collapse": "Collapse unit",
|
||||
"team.expand": "Expand unit",
|
||||
"team.openOrganisation": "Edit organisation",
|
||||
"nav.services": "Services",
|
||||
"nav.group.analytics": "Analytics",
|
||||
"nav.usage": "Token Usage",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// ClawdDotNet — Team overview (#711)
|
||||
//
|
||||
// Who is working, who reached whom in the last seconds, and where something is stuck — at a
|
||||
// glance, like looking across an office. Stage 0: the agents of the active instance and the
|
||||
// live links between them. With units (#683) they become collapsible containers.
|
||||
// glance, like looking across an office. Stage 0 drew the agents and the live links between
|
||||
// them. Stage 1 (with #683) draws units as collapsible containers: a collapsed unit shows
|
||||
// what happens inside it, so a problem three levels down is still visible from the top.
|
||||
//
|
||||
// Three rules from the concept (Organisation-Konzept, 10a):
|
||||
// - The layout comes from the structure, not from the hand: no free node editor.
|
||||
// - The state is decided by the instance (code), the page only draws it.
|
||||
// - The state is decided by the instance (code), the page only draws it. That includes the
|
||||
// rolled-up counts of a unit — the page does not add them up.
|
||||
// - A click opens a side panel, not the chat — most problems are solved by an approval.
|
||||
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
@@ -27,6 +29,15 @@ const LINK_VISIBLE_MS = 30_000;
|
||||
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
/** Up to this many agents, every unit starts expanded; above it, only the top level shows. */
|
||||
const EXPAND_ALL_UP_TO = 15;
|
||||
|
||||
/** Unit node ids are prefixed so a unit and an agent may share a name. */
|
||||
const UNIT_PREFIX = "unit:";
|
||||
|
||||
/** Parent chains longer than this are a cycle — the instance reports it, the page must not hang. */
|
||||
const MAX_DEPTH = 64;
|
||||
|
||||
// Every state has a colour AND a symbol: colour alone is not enough.
|
||||
const STATES = {
|
||||
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
|
||||
@@ -40,7 +51,11 @@ const STATES = {
|
||||
let librariesLoaded = null;
|
||||
let cy = null;
|
||||
let snapshot = null;
|
||||
let selectedAgent = null;
|
||||
let unitsById = new Map();
|
||||
let membersById = new Map();
|
||||
let expanded = null;
|
||||
let structureKey = "";
|
||||
let selected = null;
|
||||
let panel = null;
|
||||
let summary = null;
|
||||
let unsubscribe = null;
|
||||
@@ -49,6 +64,9 @@ let fadeTimer = null;
|
||||
let refreshPending = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
/** Links as observed, agent to agent: "from->to" → { from, to, seenAt, count }. */
|
||||
const links = new Map();
|
||||
|
||||
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
|
||||
let layoutWidth = 0;
|
||||
|
||||
@@ -114,11 +132,14 @@ export async function renderTeam() {
|
||||
}
|
||||
|
||||
canvas.replaceChildren();
|
||||
indexSnapshot();
|
||||
expanded = loadExpanded();
|
||||
for (const link of snapshot.links ?? []) recordLink(link);
|
||||
drawGraph(canvas);
|
||||
|
||||
unsubscribe = abonnieren("activity", onActivity);
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS);
|
||||
fadeTimer = setInterval(fadeLinks, 1_000);
|
||||
fadeTimer = setInterval(drawLinks, 1_000);
|
||||
}
|
||||
|
||||
/** Called when the route changes: stop listening, stop timers, free the graph. */
|
||||
@@ -133,7 +154,84 @@ export function leaveTeam() {
|
||||
resizeObserver = null;
|
||||
cy?.destroy();
|
||||
cy = null;
|
||||
selectedAgent = null;
|
||||
selected = null;
|
||||
structureKey = "";
|
||||
layoutWidth = 0;
|
||||
links.clear();
|
||||
}
|
||||
|
||||
// ─── The tree ───
|
||||
|
||||
function indexSnapshot() {
|
||||
unitsById = new Map((snapshot.units ?? []).map((u) => [u.id, u]));
|
||||
membersById = new Map(snapshot.members.map((m) => [m.agentId, m]));
|
||||
}
|
||||
|
||||
/** The unit and every unit above it, nearest first; stops at a cycle. */
|
||||
function chain(unitId) {
|
||||
const result = [];
|
||||
for (let unit = unitsById.get(unitId); unit && result.length < MAX_DEPTH; unit = unitsById.get(unit.parentId)) {
|
||||
if (result.includes(unit)) break;
|
||||
result.push(unit);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Whether the unit and everything above it are expanded — then its content is drawn. */
|
||||
function isOpen(unitId) {
|
||||
return chain(unitId).every((u) => expanded.has(u.id));
|
||||
}
|
||||
|
||||
/** Whether the unit itself is drawn, as a box or as a container. */
|
||||
function isVisible(unit) {
|
||||
return !unit.parentId || !unitsById.has(unit.parentId) || isOpen(unit.parentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an agent is drawn: itself if its unit is open, otherwise the topmost collapsed unit
|
||||
* on its path. Links are drawn between these, so a collapsed unit collects the links of
|
||||
* everyone inside it.
|
||||
*/
|
||||
function representative(agentId) {
|
||||
const member = membersById.get(agentId);
|
||||
if (!member) return null;
|
||||
let rep = null;
|
||||
for (const unit of chain(member.unitId)) {
|
||||
if (!expanded.has(unit.id)) rep = unit.id;
|
||||
}
|
||||
return rep ? UNIT_PREFIX + rep : agentId;
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return `clawd.team.expanded.${holeAktiveInstanz()}`;
|
||||
}
|
||||
|
||||
function loadExpanded() {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey());
|
||||
if (stored) return new Set(JSON.parse(stored));
|
||||
} catch {
|
||||
// Private window or blocked storage: fall back to the default below.
|
||||
}
|
||||
// A small team is shown whole; a large one starts at the departments.
|
||||
return snapshot.members.length <= EXPAND_ALL_UP_TO
|
||||
? new Set(unitsById.keys())
|
||||
: new Set();
|
||||
}
|
||||
|
||||
function saveExpanded() {
|
||||
try {
|
||||
localStorage.setItem(storageKey(), JSON.stringify([...expanded]));
|
||||
} catch {
|
||||
// Only a convenience — the default applies next time.
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUnit(unitId, open) {
|
||||
if (open ?? !expanded.has(unitId)) expanded.add(unitId);
|
||||
else expanded.delete(unitId);
|
||||
saveExpanded();
|
||||
applySnapshot();
|
||||
}
|
||||
|
||||
// ─── Graph ───
|
||||
@@ -143,8 +241,9 @@ function drawGraph(canvas) {
|
||||
container: canvas,
|
||||
elements: [],
|
||||
wheelSensitivity: 0.2,
|
||||
minZoom: 0.3,
|
||||
maxZoom: 2.5,
|
||||
minZoom: 0.2,
|
||||
// A handful of collapsed units would otherwise fill the area with giant boxes.
|
||||
maxZoom: 1.6,
|
||||
// Nodes are not dragged: the layout comes from the structure, not the hand.
|
||||
autoungrabify: true,
|
||||
style: [
|
||||
@@ -168,26 +267,74 @@ function drawGraph(canvas) {
|
||||
}
|
||||
},
|
||||
{ selector: "node.aktiv", style: { "border-width": 4 } },
|
||||
{ selector: "node.leitung", style: { "font-weight": "bold" } },
|
||||
// A collapsed unit: a box that stands for everyone inside it.
|
||||
{
|
||||
selector: "node.einheit",
|
||||
style: {
|
||||
shape: "round-rectangle",
|
||||
width: 210,
|
||||
height: 70,
|
||||
"border-width": 3,
|
||||
"border-style": "double",
|
||||
"background-color": token("--surface-subtil"),
|
||||
"font-weight": "bold"
|
||||
}
|
||||
},
|
||||
{ selector: "node.einheit.aktiv", style: { "border-width": 5 } },
|
||||
// An expanded unit: a container around its members and sub-units.
|
||||
{
|
||||
selector: "node:parent",
|
||||
style: {
|
||||
shape: "round-rectangle",
|
||||
padding: 20,
|
||||
"border-width": 2,
|
||||
"border-style": "dashed",
|
||||
"background-color": token("--surface-subtil"),
|
||||
"background-opacity": 0.5,
|
||||
"text-valign": "top",
|
||||
"text-halign": "center",
|
||||
"text-margin-y": -4,
|
||||
"text-max-width": 400,
|
||||
"font-weight": "bold"
|
||||
}
|
||||
},
|
||||
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 3,
|
||||
width: "data(width)",
|
||||
"line-color": token("--accent"),
|
||||
"target-arrow-color": token("--accent"),
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
opacity: "data(opacity)"
|
||||
opacity: "data(opacity)",
|
||||
label: "data(label)",
|
||||
"font-size": 11,
|
||||
color: token("--text"),
|
||||
"text-background-color": token("--surface"),
|
||||
"text-background-opacity": 1,
|
||||
"text-background-padding": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
cy.on("tap", "node", (event) => openPanel(event.target.id()));
|
||||
cy.on("tap", "node", (event) => {
|
||||
const id = event.target.id();
|
||||
if (!id.startsWith(UNIT_PREFIX)) {
|
||||
openPanel({ agent: id });
|
||||
return;
|
||||
}
|
||||
const unitId = id.slice(UNIT_PREFIX.length);
|
||||
// A collapsed unit opens on the first click — looking inside is what the click is for.
|
||||
if (!expanded.has(unitId)) toggleUnit(unitId, true);
|
||||
openPanel({ unit: unitId });
|
||||
});
|
||||
cy.on("dbltap", "node:parent", (event) => toggleUnit(event.target.id().slice(UNIT_PREFIX.length), false));
|
||||
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
|
||||
|
||||
applySnapshot(true);
|
||||
for (const link of snapshot.links ?? []) addLink(link);
|
||||
applySnapshot();
|
||||
observeSize(canvas);
|
||||
}
|
||||
|
||||
@@ -207,58 +354,164 @@ function observeSize(canvas) {
|
||||
resizeObserver.observe(canvas);
|
||||
}
|
||||
|
||||
function runLayout() {
|
||||
/** Counts layouts, so a layout overtaken by a newer one stops trying. */
|
||||
let layoutGeneration = 0;
|
||||
|
||||
/** Runs one ELK layout with the given aspect ratio and returns the ratio it achieved. */
|
||||
function layoutOnce(aspectRatio) {
|
||||
return new Promise((resolve) => {
|
||||
const layout = cy.layout({
|
||||
name: "elk",
|
||||
fit: false,
|
||||
// The agents are packed evenly into the shape of the area, inside each container as
|
||||
// on the top level. rectpacking keeps the order they were added in (units by path,
|
||||
// the lead first), so a box does not jump around when a state changes.
|
||||
elk: {
|
||||
algorithm: "rectpacking",
|
||||
"elk.spacing.nodeNode": 40,
|
||||
"elk.aspectRatio": aspectRatio
|
||||
},
|
||||
// Room for the container's own padding and its label above the members.
|
||||
nodeLayoutOptions: (node) => node.isParent()
|
||||
? {
|
||||
"elk.algorithm": "rectpacking",
|
||||
"elk.padding": "[top=44,left=24,bottom=24,right=24]",
|
||||
"elk.spacing.nodeNode": 24
|
||||
}
|
||||
: {}
|
||||
});
|
||||
// ELK computes asynchronously; measure once the positions are there.
|
||||
layout.one("layoutstop", () => {
|
||||
const box = cy?.elements().boundingBox();
|
||||
resolve(box && box.h > 0 ? box.w / box.h : 1);
|
||||
});
|
||||
layout.run();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the graph out in the shape of the area. rectpacking treats the aspect ratio as a
|
||||
* hint and, with a few large containers on the top level, jumps between a handful of
|
||||
* shapes — asked for 1.4 it may deliver 0.6 or 2.6. So a few hints are tried and the
|
||||
* result closest to the area is kept; with the fit afterwards, that is the one that shows
|
||||
* the boxes largest.
|
||||
*/
|
||||
async function runLayout() {
|
||||
const area = cy.container();
|
||||
layoutWidth = area.clientWidth;
|
||||
const layout = cy.layout({
|
||||
name: "elk",
|
||||
fit: false,
|
||||
// Stage 0 has no structure yet, so the agents are packed evenly into the shape of
|
||||
// the area. rectpacking keeps their order, so a box does not jump around when a
|
||||
// state changes. With units (#683) this becomes a layered layout of nested containers.
|
||||
elk: {
|
||||
algorithm: "rectpacking",
|
||||
"elk.spacing.nodeNode": 40,
|
||||
"elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0
|
||||
? Math.max(0.5, area.clientWidth / area.clientHeight)
|
||||
: 2
|
||||
}
|
||||
});
|
||||
// ELK computes asynchronously; fit once the positions are there.
|
||||
layout.one("layoutstop", () => cy?.fit(undefined, 30));
|
||||
layout.run();
|
||||
const target = area.clientWidth > 0 && area.clientHeight > 0
|
||||
? Math.max(0.5, area.clientWidth / area.clientHeight)
|
||||
: 2;
|
||||
const generation = ++layoutGeneration;
|
||||
const miss = (ratio) => Math.abs(Math.log(ratio / target));
|
||||
|
||||
let best = null;
|
||||
for (const hint of [target, target * 1.2, target * 1.45, target * 0.8]) {
|
||||
const ratio = await layoutOnce(hint);
|
||||
if (!cy || generation !== layoutGeneration) return;
|
||||
if (!best || miss(ratio) < best.miss) best = { hint, miss: miss(ratio), last: true };
|
||||
else best.last = false;
|
||||
if (best.miss < 0.15) break;
|
||||
}
|
||||
// The best one may not be the last one run.
|
||||
if (!best.last) await layoutOnce(best.hint);
|
||||
if (cy && generation === layoutGeneration) cy.fit(undefined, 30);
|
||||
}
|
||||
|
||||
function nodeLabel(member) {
|
||||
function agentLabel(member) {
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
|
||||
return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
|
||||
const lead = member.isLead ? "★ " : "";
|
||||
return `${state.symbol} ${lead}${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
|
||||
}
|
||||
|
||||
function applySnapshot(relayout) {
|
||||
if (!cy || !snapshot) return;
|
||||
/** "5 agents · ▶ 2 · ⚠ 1" — everything that is not idle, most urgent first. */
|
||||
function unitCounts(unit) {
|
||||
const parts = [unit.agents === 1 ? t("team.unitAgentsOne") : t("team.unitAgents", { n: unit.agents })];
|
||||
for (const state of ["Stuck", "Problem", "WaitingForApproval", "BudgetExhausted", "Working"]) {
|
||||
const n = unit.states?.[state];
|
||||
if (n) parts.push(`${STATES[state].symbol} ${n}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
const present = new Set();
|
||||
for (const member of snapshot.members) {
|
||||
present.add(member.agentId);
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) };
|
||||
const node = cy.getElementById(member.agentId);
|
||||
if (node.nonempty()) {
|
||||
node.data(data);
|
||||
} else {
|
||||
cy.add({ group: "nodes", data });
|
||||
relayout = true;
|
||||
}
|
||||
cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
|
||||
function unitLabel(unit, open) {
|
||||
return open
|
||||
? `▾ ${unit.name} · ${unitCounts(unit)}`
|
||||
: `▸ ${unit.name}\n${unitCounts(unit)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes as they should be now, parents before children and in a stable order: agents
|
||||
* without a unit, then the units by path, each with its lead first.
|
||||
*/
|
||||
function desiredNodes() {
|
||||
const nodes = [];
|
||||
const byName = (a, b) => (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId);
|
||||
const colour = (state) => token((STATES[state] ?? STATES.Idle).colour);
|
||||
|
||||
for (const member of snapshot.members.filter((m) => !m.unitId || !unitsById.has(m.unitId)).sort(byName)) {
|
||||
nodes.push({ id: member.agentId, member, colour: colour(member.state) });
|
||||
}
|
||||
|
||||
cy.nodes().filter((n) => !present.has(n.id())).remove();
|
||||
for (const unit of snapshot.units ?? []) {
|
||||
if (!isVisible(unit)) continue;
|
||||
const open = expanded.has(unit.id);
|
||||
const parent = unit.parentId && unitsById.has(unit.parentId) ? UNIT_PREFIX + unit.parentId : undefined;
|
||||
nodes.push({ id: UNIT_PREFIX + unit.id, parent, unit, open, colour: colour(unit.worst) });
|
||||
if (!open) continue;
|
||||
|
||||
if (relayout) runLayout();
|
||||
const members = snapshot.members
|
||||
.filter((m) => m.unitId === unit.id)
|
||||
.sort((a, b) => (b.isLead - a.isLead) || byName(a, b));
|
||||
for (const member of members) {
|
||||
nodes.push({ id: member.agentId, parent: UNIT_PREFIX + unit.id, member, colour: colour(member.state) });
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the graph up to date. When only states changed, the nodes are updated in place and
|
||||
* nothing moves. When the structure changed — a unit opened or closed, an agent moved — the
|
||||
* nodes are rebuilt and laid out again: Cytoscape cannot turn a box into a container.
|
||||
*/
|
||||
function applySnapshot() {
|
||||
if (!cy || !snapshot) return;
|
||||
|
||||
const nodes = desiredNodes();
|
||||
const key = nodes.map((n) => `${n.id}<${n.parent ?? ""}${n.open ? "+" : ""}`).join("|");
|
||||
const data = (n) => ({
|
||||
id: n.id,
|
||||
parent: n.parent,
|
||||
colour: n.colour,
|
||||
label: n.unit ? unitLabel(n.unit, n.open) : agentLabel(n.member)
|
||||
});
|
||||
|
||||
if (key !== structureKey) {
|
||||
structureKey = key;
|
||||
cy.elements().remove();
|
||||
cy.add(nodes.map((n) => ({ group: "nodes", data: data(n) })));
|
||||
for (const n of nodes) decorate(n);
|
||||
drawLinks();
|
||||
runLayout();
|
||||
} else {
|
||||
for (const n of nodes) {
|
||||
const { parent: _parent, ...rest } = data(n);
|
||||
cy.getElementById(n.id).data(rest);
|
||||
decorate(n);
|
||||
}
|
||||
}
|
||||
|
||||
renderSummary();
|
||||
if (selectedAgent) openPanel(selectedAgent);
|
||||
if (selected) openPanel(selected);
|
||||
}
|
||||
|
||||
function decorate(n) {
|
||||
const node = cy.getElementById(n.id);
|
||||
node.toggleClass("einheit", Boolean(n.unit) && !n.open);
|
||||
node.toggleClass("aktiv", Boolean(n.member?.runs.length) || Boolean(n.unit && !n.open && n.unit.states?.Working));
|
||||
node.toggleClass("leitung", Boolean(n.member?.isLead));
|
||||
}
|
||||
|
||||
// ─── Live ───
|
||||
@@ -271,7 +524,10 @@ function forActiveInstance(data) {
|
||||
function onActivity(data) {
|
||||
if (!cy || !forActiveInstance(data)) return;
|
||||
|
||||
if (data.kind === "link" && data.link) addLink(data.link);
|
||||
if (data.kind === "link" && data.link) {
|
||||
recordLink(data.link);
|
||||
drawLinks();
|
||||
}
|
||||
|
||||
// Start and end change states; ask the instance instead of guessing here.
|
||||
clearTimeout(refreshPending);
|
||||
@@ -282,34 +538,65 @@ async function refresh() {
|
||||
if (!cy) return;
|
||||
try {
|
||||
snapshot = await api("/team");
|
||||
applySnapshot(false);
|
||||
indexSnapshot();
|
||||
applySnapshot();
|
||||
} catch {
|
||||
// The next event or tick tries again; a failed refresh keeps the last picture.
|
||||
}
|
||||
}
|
||||
|
||||
function addLink(link) {
|
||||
if (!cy) return;
|
||||
const age = Date.now() - new Date(link.atUtc).getTime();
|
||||
if (age > LINK_VISIBLE_MS) return;
|
||||
if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return;
|
||||
|
||||
// One edge per pair; a new contact renews it.
|
||||
const id = `link:${link.from}->${link.to}`;
|
||||
const existing = cy.getElementById(id);
|
||||
const data = { id, source: link.from, target: link.to, seenAt: new Date(link.atUtc).getTime(), opacity: 1 };
|
||||
if (existing.nonempty()) existing.data(data);
|
||||
else cy.add({ group: "edges", data });
|
||||
function recordLink(link) {
|
||||
const seenAt = new Date(link.atUtc).getTime();
|
||||
if (Date.now() - seenAt > LINK_VISIBLE_MS) return;
|
||||
const key = `${link.from}->${link.to}`;
|
||||
const known = links.get(key);
|
||||
// A new contact renews the pair and counts; the snapshot may repeat one already seen.
|
||||
if (known && known.seenAt >= seenAt) return;
|
||||
links.set(key, { from: link.from, to: link.to, seenAt, count: (known?.count ?? 0) + 1 });
|
||||
}
|
||||
|
||||
function fadeLinks() {
|
||||
/**
|
||||
* The edges between what is drawn: links of agents inside a collapsed unit start or end at
|
||||
* that unit, and several such links become one edge with a counter. A link that stays
|
||||
* inside one collapsed unit is not drawn — it is the unit talking to itself.
|
||||
*/
|
||||
function drawLinks() {
|
||||
if (!cy) return;
|
||||
const now = Date.now();
|
||||
cy.edges().forEach((edge) => {
|
||||
const age = now - edge.data("seenAt");
|
||||
if (age > LINK_VISIBLE_MS) edge.remove();
|
||||
else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS));
|
||||
});
|
||||
const bundles = new Map();
|
||||
|
||||
for (const [key, link] of links) {
|
||||
const age = now - link.seenAt;
|
||||
if (age > LINK_VISIBLE_MS) {
|
||||
links.delete(key);
|
||||
continue;
|
||||
}
|
||||
const from = representative(link.from);
|
||||
const to = representative(link.to);
|
||||
if (!from || !to || from === to) continue;
|
||||
if (cy.getElementById(from).empty() || cy.getElementById(to).empty()) continue;
|
||||
|
||||
const id = `link:${from}->${to}`;
|
||||
const bundle = bundles.get(id) ?? { id, source: from, target: to, seenAt: 0, count: 0 };
|
||||
bundle.seenAt = Math.max(bundle.seenAt, link.seenAt);
|
||||
bundle.count += link.count;
|
||||
bundles.set(id, bundle);
|
||||
}
|
||||
|
||||
cy.edges().filter((edge) => !bundles.has(edge.id())).remove();
|
||||
for (const bundle of bundles.values()) {
|
||||
const data = {
|
||||
id: bundle.id,
|
||||
source: bundle.source,
|
||||
target: bundle.target,
|
||||
opacity: Math.max(0.15, 1 - (now - bundle.seenAt) / LINK_VISIBLE_MS),
|
||||
width: Math.min(8, 2 + bundle.count),
|
||||
label: bundle.count > 1 ? `×${bundle.count}` : ""
|
||||
};
|
||||
const edge = cy.getElementById(bundle.id);
|
||||
if (edge.nonempty()) edge.data(data);
|
||||
else cy.add({ group: "edges", data });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Summary and legend ───
|
||||
@@ -334,32 +621,56 @@ function buildLegend() {
|
||||
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
|
||||
...Object.entries(STATES).map(([state, s]) =>
|
||||
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
|
||||
erstelleElement("span", { text: `★ ${t("team.legendLead")}` }),
|
||||
erstelleElement("span", { text: `▸ ${t("team.legendUnit")}` }),
|
||||
erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Side panel ───
|
||||
|
||||
function openPanel(agentId) {
|
||||
const member = snapshot?.members.find((m) => m.agentId === agentId);
|
||||
if (!member || !panel) return;
|
||||
selectedAgent = agentId;
|
||||
cy?.getElementById(agentId).select();
|
||||
function openPanel(target) {
|
||||
if (!panel || !snapshot) return;
|
||||
const rows = target.unit ? unitRows(target.unit) : agentRows(target.agent);
|
||||
if (!rows) return;
|
||||
|
||||
selected = target;
|
||||
cy?.elements().unselect();
|
||||
cy?.getElementById(target.unit ? UNIT_PREFIX + target.unit : target.agent).select();
|
||||
|
||||
panel.replaceChildren(...rows);
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function panelHead(title) {
|
||||
return erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
|
||||
erstelleElement("h2", { text: title }),
|
||||
erstelleElement("button", {
|
||||
klasse: "schaltflaeche flach klein",
|
||||
text: "×",
|
||||
title: t("common.close"),
|
||||
onclick: closePanel
|
||||
}));
|
||||
}
|
||||
|
||||
function agentRows(agentId) {
|
||||
const member = membersById.get(agentId);
|
||||
if (!member) return null;
|
||||
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
const unit = unitsById.get(member.unitId);
|
||||
const rows = [
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
|
||||
erstelleElement("h2", { text: member.displayName || member.agentId }),
|
||||
erstelleElement("button", {
|
||||
klasse: "schaltflaeche flach klein",
|
||||
text: "×",
|
||||
title: t("common.close"),
|
||||
onclick: closePanel
|
||||
})),
|
||||
panelHead(member.displayName || member.agentId),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
|
||||
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
|
||||
];
|
||||
|
||||
if (unit) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein" },
|
||||
`${member.isLead ? `★ ${t("team.leads")} ` : `${t("team.memberOf")} `}`,
|
||||
unitButton(unit)));
|
||||
}
|
||||
|
||||
// Top layer: what is happening now.
|
||||
if (member.runs.length) {
|
||||
rows.push(erstelleElement("h3", { text: t("team.running") }));
|
||||
@@ -408,9 +719,78 @@ function openPanel(agentId) {
|
||||
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
|
||||
);
|
||||
rows.push(actions);
|
||||
return rows;
|
||||
}
|
||||
|
||||
panel.replaceChildren(...rows);
|
||||
panel.hidden = false;
|
||||
function unitRows(unitId) {
|
||||
const unit = unitsById.get(unitId);
|
||||
if (!unit) return null;
|
||||
|
||||
const worst = STATES[unit.worst] ?? STATES.Idle;
|
||||
const rows = [
|
||||
panelHead(unit.name),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: unit.path }),
|
||||
erstellePlakette(`${worst.symbol} ${unitCounts(unit)}`, worst.tone)
|
||||
];
|
||||
|
||||
if (unit.goal) rows.push(erstelleElement("p", { klasse: "text-klein", text: unit.goal }));
|
||||
|
||||
if (unit.pendingApprovals > 0) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: unit.pendingApprovals })}` }));
|
||||
}
|
||||
|
||||
// Direct members, the lead first; what is further down is one click on a sub-unit away.
|
||||
rows.push(erstelleElement("h3", { text: t("team.members") }));
|
||||
const members = snapshot.members
|
||||
.filter((m) => m.unitId === unit.id)
|
||||
.sort((a, b) => (b.isLead - a.isLead) || (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId));
|
||||
if (!members.length) rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
|
||||
for (const member of members) {
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
rows.push(erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "flach klein team-eintrag",
|
||||
text: `${state.symbol} ${member.isLead ? "★ " : ""}${member.displayName || member.agentId} — ${t(`team.state.${member.state}`)}`,
|
||||
onclick: () => openPanel({ agent: member.agentId })
|
||||
}));
|
||||
}
|
||||
|
||||
const children = (snapshot.units ?? []).filter((u) => u.parentId === unit.id);
|
||||
if (children.length) {
|
||||
rows.push(erstelleElement("h3", { text: t("team.subUnits") }));
|
||||
for (const child of children) rows.push(erstelleElement("div", {}, unitButton(child)));
|
||||
}
|
||||
|
||||
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
|
||||
const open = expanded.has(unit.id);
|
||||
actions.append(
|
||||
erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "primaer klein",
|
||||
text: open ? t("team.collapse") : t("team.expand"),
|
||||
onclick: () => toggleUnit(unit.id, !open)
|
||||
}),
|
||||
link("#/organisation", t("team.openOrganisation"))
|
||||
);
|
||||
rows.push(actions);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A unit as a link in the panel, with its most urgent state. */
|
||||
function unitButton(unit) {
|
||||
const worst = STATES[unit.worst] ?? STATES.Idle;
|
||||
return erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "flach klein team-eintrag",
|
||||
text: `${worst.symbol} ${unit.name} · ${unitCounts(unit)}`,
|
||||
onclick: () => {
|
||||
// Show it in the graph too: open everything above it.
|
||||
for (const above of chain(unit.parentId)) expanded.add(above.id);
|
||||
saveExpanded();
|
||||
applySnapshot();
|
||||
openPanel({ unit: unit.id });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function link(href, text, style = "flach") {
|
||||
@@ -423,7 +803,7 @@ function link(href, text, style = "flach") {
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
selectedAgent = null;
|
||||
selected = null;
|
||||
cy?.elements().unselect();
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
|
||||
@@ -125,8 +125,29 @@ public sealed record TaskDto(
|
||||
/// </summary>
|
||||
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
|
||||
|
||||
/// <summary>#711: The team overview — every agent and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshotDto(IReadOnlyList<TeamMemberDto> Members, IReadOnlyList<AgentLinkDto> Links, DateTime AtUtc);
|
||||
/// <summary>#711: The team overview — every agent and unit, and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshotDto(
|
||||
IReadOnlyList<TeamMemberDto> Members,
|
||||
IReadOnlyList<AgentLinkDto> Links,
|
||||
DateTime AtUtc,
|
||||
IReadOnlyList<TeamUnitDto> Units);
|
||||
|
||||
/// <summary>
|
||||
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
|
||||
/// instance. <see cref="States"/> maps a state name to a count; <see cref="Worst"/> is the
|
||||
/// most urgent state inside.
|
||||
/// </summary>
|
||||
public sealed record TeamUnitDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string? ParentId,
|
||||
string Path,
|
||||
string? LeaderAgentId,
|
||||
string? Goal,
|
||||
int Agents,
|
||||
IReadOnlyDictionary<string, int> States,
|
||||
int PendingApprovals,
|
||||
string Worst);
|
||||
|
||||
/// <summary>
|
||||
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
|
||||
@@ -142,7 +163,9 @@ public sealed record TeamMemberDto(
|
||||
string? LastRunStatus,
|
||||
DateTime? LastRunAtUtc,
|
||||
string? LastRunResult,
|
||||
long TypicalRunMs);
|
||||
long TypicalRunMs,
|
||||
string? UnitId,
|
||||
bool IsLead);
|
||||
|
||||
/// <summary>#711: A run in progress.</summary>
|
||||
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
|
||||
|
||||
@@ -74,9 +74,14 @@ internal static class Mapping
|
||||
snapshot.Members.Select(m => new TeamMemberDto(
|
||||
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
|
||||
m.Runs.Select(ToDto).ToList(),
|
||||
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(),
|
||||
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs,
|
||||
m.UnitId, m.IsLead)).ToList(),
|
||||
snapshot.RecentLinks.Select(ToDto).ToList(),
|
||||
snapshot.AtUtc);
|
||||
snapshot.AtUtc,
|
||||
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
|
||||
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.PendingApprovals, u.Worst.ToString())).ToList());
|
||||
|
||||
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
|
||||
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class TaskAdministrationTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
|
||||
try { Directory.Delete(_dir, recursive: true); }
|
||||
catch { /* Aufraeumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Activity;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
@@ -79,4 +81,85 @@ public sealed class TeamOverviewTests
|
||||
public void A_cancelled_run_is_not_a_problem()
|
||||
// Cancelled means a human stopped it — that was a decision, not a fault.
|
||||
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
|
||||
|
||||
// ═══════════════════ stage 1: units roll up ═══════════════════
|
||||
|
||||
/// <summary>marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.</summary>
|
||||
private static OrgChart Shop() => new(
|
||||
[
|
||||
new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
|
||||
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
|
||||
new UnitConfig { Id = "accounting", Name = "Accounting" }
|
||||
],
|
||||
[
|
||||
new AgentConfig { AgentId = "ceo" },
|
||||
new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" },
|
||||
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
|
||||
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
|
||||
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
|
||||
]);
|
||||
|
||||
private static TeamMember Member(string id, string? unit, TeamState state, int approvals = 0)
|
||||
=> new(id, id, "m", state, [], approvals, null, null, null, 0, unit);
|
||||
|
||||
[Fact]
|
||||
public void A_problem_deep_down_shows_on_every_container_above_it()
|
||||
{
|
||||
var units = TeamOverview.RollUp(Shop(),
|
||||
[
|
||||
Member("ceo", null, TeamState.Working),
|
||||
Member("marketing-lead", "marketing", TeamState.Working),
|
||||
Member("amazon-lead", "amazon", TeamState.Idle),
|
||||
Member("ads", "amazon", TeamState.Problem),
|
||||
Member("bookkeeper", "accounting", TeamState.Idle)
|
||||
]);
|
||||
|
||||
var marketing = units.Single(u => u.Id == "marketing");
|
||||
marketing.Agents.ShouldBe(3, "its own lead and the two in amazon below");
|
||||
marketing.Worst.ShouldBe(TeamState.Problem);
|
||||
marketing.States[TeamState.Working].ShouldBe(1);
|
||||
marketing.States[TeamState.Problem].ShouldBe(1);
|
||||
|
||||
units.Single(u => u.Id == "amazon").Worst.ShouldBe(TeamState.Problem);
|
||||
units.Single(u => u.Id == "accounting").Worst.ShouldBe(TeamState.Idle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_approvals_are_added_up_along_the_tree()
|
||||
{
|
||||
var units = TeamOverview.RollUp(Shop(),
|
||||
[
|
||||
Member("marketing-lead", "marketing", TeamState.WaitingForApproval, approvals: 1),
|
||||
Member("ads", "amazon", TeamState.WaitingForApproval, approvals: 2)
|
||||
]);
|
||||
|
||||
units.Single(u => u.Id == "marketing").PendingApprovals.ShouldBe(3);
|
||||
units.Single(u => u.Id == "amazon").PendingApprovals.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_unit_knows_its_path_and_its_lead()
|
||||
{
|
||||
var amazon = TeamOverview.RollUp(Shop(), []).Single(u => u.Id == "amazon");
|
||||
|
||||
amazon.Path.ShouldBe("Marketing / Amazon");
|
||||
amazon.LeaderAgentId.ShouldBe("amazon-lead");
|
||||
amazon.Agents.ShouldBe(0);
|
||||
amazon.Worst.ShouldBe(TeamState.Idle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stuck_is_the_most_urgent_and_idle_the_least()
|
||||
{
|
||||
var byUrgency = Enum.GetValues<TeamState>().OrderByDescending(TeamOverview.Urgency).ToList();
|
||||
|
||||
byUrgency.First().ShouldBe(TeamState.Stuck);
|
||||
byUrgency.Last().ShouldBe(TeamState.Idle);
|
||||
TeamOverview.Urgency(TeamState.Problem).ShouldBeGreaterThan(TeamOverview.Urgency(TeamState.WaitingForApproval));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_units_there_is_nothing_to_roll_up()
|
||||
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class AuditRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class BackupServiceTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
@@ -95,7 +95,7 @@ public sealed class BackupServiceTests : IDisposable
|
||||
}, default);
|
||||
|
||||
// Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft.
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_instanceDir);
|
||||
}
|
||||
|
||||
private string ZipPath => Path.Combine(_root, "sicherung.zip");
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class LicenseBackupTests : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
SecretKeyStore.UseDirectory(null);
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class BudgetGuardTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
@@ -220,7 +220,7 @@ public sealed class UsageRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class MemoryRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class StagingRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class StagingServiceTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class WerkzeugrechteTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Releasing the pool of one database must leave every other database alone. The tests used
|
||||
/// the process-wide ClearAllPools to clean up, and a test running in parallel lost its
|
||||
/// connection in the middle of opening it — one red run in every few, always another test.
|
||||
/// </summary>
|
||||
public sealed class PoolReleaseTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "pool-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private static async Task TouchAsync(SqliteStorage storage)
|
||||
=> await storage.WriteAsync(async connection =>
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES ('k', 'v')";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}, default);
|
||||
|
||||
[Fact]
|
||||
public async Task Releasing_one_directory_frees_its_files_and_nothing_else()
|
||||
{
|
||||
var first = new SqliteStorage(Path.Combine(_root, "a", "state.db"));
|
||||
var second = new SqliteStorage(Path.Combine(_root, "b", "state.db"));
|
||||
await TouchAsync(first);
|
||||
await TouchAsync(second);
|
||||
|
||||
SqliteStorage.ReleasePooledConnections(Path.Combine(_root, "a"));
|
||||
|
||||
// The released database can go — on Windows an open handle would prevent that.
|
||||
Should.NotThrow(() => Directory.Delete(Path.Combine(_root, "a"), recursive: true));
|
||||
|
||||
// The other one keeps working, and so does its pool.
|
||||
await TouchAsync(second);
|
||||
await using var connection = await second.OpenConnectionAsync(default);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = 'k'";
|
||||
(await cmd.ExecuteScalarAsync()).ShouldBe("v");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_database_file_can_be_named_directly()
|
||||
{
|
||||
var path = Path.Combine(_root, "c", "state.db");
|
||||
await TouchAsync(new SqliteStorage(path));
|
||||
|
||||
SqliteStorage.ReleasePooledConnections(path);
|
||||
|
||||
Should.NotThrow(() => File.Delete(path));
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public sealed class BudgetMissTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class CoordinationMigrationTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class ScannerCapacityTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class TaskScannerTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class TaskboardServiceTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class PromptHistoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is best effort */ }
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class MemoryToolTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
|
||||
try { Directory.Delete(_dir, recursive: true); }
|
||||
catch { /* Aufraeumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class AssignmentReachTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class DublettenTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_verzeichnis);
|
||||
try { Directory.Delete(_verzeichnis, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user