Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f627918729 | ||
|
|
d587dc3f20 | ||
|
|
991a901cab | ||
|
|
0f49e04261 | ||
|
|
fe0eb09d7c |
@@ -24,3 +24,6 @@
|
||||
*.jpg binary
|
||||
*.zip binary
|
||||
*.dll binary
|
||||
|
||||
# Vendored third-party files stay byte for byte as published (checksums in vendor/README.md)
|
||||
src/ClawdDotNet.Manager.Web/wwwroot/vendor/** -text
|
||||
|
||||
@@ -113,6 +113,9 @@ public sealed class AppHost : IAsyncDisposable
|
||||
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
|
||||
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 TelegramClientManager? TelegramClient { get; private init; }
|
||||
public OpenRouterStatusService? Status { get; private init; }
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Activity;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>What an agent is doing, as the team overview shows it (#711).</summary>
|
||||
public enum TeamState
|
||||
{
|
||||
/// <summary>Nothing running, nothing open, the last run went fine (or there was none).</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>At least one run in progress.</summary>
|
||||
Working,
|
||||
|
||||
/// <summary>A run has taken much longer than this agent's runs usually take.</summary>
|
||||
Stuck,
|
||||
|
||||
/// <summary>Tool calls of this agent wait for a human decision.</summary>
|
||||
WaitingForApproval,
|
||||
|
||||
/// <summary>The last run failed, hit a limit or found the target busy.</summary>
|
||||
Problem,
|
||||
|
||||
/// <summary>The last run was refused because the budget is used up.</summary>
|
||||
BudgetExhausted
|
||||
}
|
||||
|
||||
/// <summary>One agent in the team overview.</summary>
|
||||
public sealed record TeamMember(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
string Model,
|
||||
TeamState State,
|
||||
IReadOnlyList<ActiveRun> Runs,
|
||||
int PendingApprovals,
|
||||
string? LastRunStatus,
|
||||
DateTime? LastRunAtUtc,
|
||||
string? LastRunResult,
|
||||
long TypicalRunMs);
|
||||
|
||||
/// <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>
|
||||
/// #711: The team overview — who is working, who reached whom, where something is stuck.
|
||||
///
|
||||
/// <para><b>Detecting is code, interpreting is model</b> (Organisation-Konzept, 10a). Every
|
||||
/// state here comes from a fact the instance recorded: a run in progress, an open staged
|
||||
/// call, the status of the last receipt. Nothing is judged by a model, so the picture is
|
||||
/// the same for everyone who looks and costs nothing to draw.</para>
|
||||
///
|
||||
/// <para><b>Order of states.</b> When several apply, the one that needs someone first wins:
|
||||
/// stuck, then waiting for approval, then working; a problem or an exhausted budget only
|
||||
/// shows while nothing new is running — a new run is the attempt to get past it.</para>
|
||||
/// </summary>
|
||||
public sealed class TeamOverview(
|
||||
AgentEngine? engine,
|
||||
StagingService? staging,
|
||||
IAuditRepository? audit,
|
||||
IUsageRepository? usage,
|
||||
Func<IReadOnlyList<AgentConfig>> agents,
|
||||
TimeProvider? clock = null)
|
||||
{
|
||||
/// <summary>How far back the last receipt of each agent is looked for.</summary>
|
||||
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
|
||||
|
||||
/// <summary>How long recent links stay in the snapshot — the overview fades them out.</summary>
|
||||
public static readonly TimeSpan LinkWindow = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>A run counts as stuck after this many times the agent's typical duration…</summary>
|
||||
public const int StuckFactor = 3;
|
||||
|
||||
/// <summary>…but never before this — short runs vary a lot in relative terms.</summary>
|
||||
public static readonly TimeSpan StuckFloor = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Without history, a run counts as stuck after this long.</summary>
|
||||
public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
||||
|
||||
public async Task<TeamSnapshot> SnapshotAsync(CancellationToken ct)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var configured = agents();
|
||||
|
||||
var active = engine?.Activity.ActiveRuns() ?? [];
|
||||
var links = engine?.Activity.RecentLinks(LinkWindow) ?? [];
|
||||
|
||||
var pending = staging is null ? [] : await staging.ListPendingAsync(ct);
|
||||
var pendingByAgent = pending
|
||||
.GroupBy(c => c.AgentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var lastReceipt = new Dictionary<string, RunReceipt>(StringComparer.OrdinalIgnoreCase);
|
||||
if (audit is not null)
|
||||
{
|
||||
var receipts = await audit.ListReceiptsAsync(now - ReceiptWindow, now, 5_000, ct);
|
||||
foreach (var receipt in receipts)
|
||||
{
|
||||
if (!lastReceipt.TryGetValue(receipt.AgentId, out var known) || receipt.OccurredAt > known.OccurredAt)
|
||||
lastReceipt[receipt.AgentId] = receipt;
|
||||
}
|
||||
}
|
||||
|
||||
var members = new List<TeamMember>();
|
||||
foreach (var agent in configured)
|
||||
{
|
||||
var runs = active
|
||||
.Where(r => string.Equals(r.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(r => r.StartedAtUtc)
|
||||
.ToList();
|
||||
var approvals = pendingByAgent.GetValueOrDefault(agent.AgentId);
|
||||
lastReceipt.TryGetValue(agent.AgentId, out var last);
|
||||
var typical = await TypicalRunAsync(agent.AgentId, ct);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
return new TeamSnapshot(members, links, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The state from the facts. Pure, so the rules can be tested without an instance.
|
||||
/// </summary>
|
||||
public static TeamState Classify(
|
||||
IReadOnlyList<ActiveRun> runs, int pendingApprovals, string? lastRunStatus, TimeSpan typicalRun, DateTime nowUtc)
|
||||
{
|
||||
if (runs.Count > 0)
|
||||
{
|
||||
var limit = typicalRun > TimeSpan.Zero
|
||||
? TimeSpan.FromTicks(Math.Max(typicalRun.Ticks * StuckFactor, StuckFloor.Ticks))
|
||||
: StuckWithoutHistory;
|
||||
if (runs.Any(r => nowUtc - r.StartedAtUtc > limit))
|
||||
return TeamState.Stuck;
|
||||
}
|
||||
|
||||
if (pendingApprovals > 0)
|
||||
return TeamState.WaitingForApproval;
|
||||
|
||||
if (runs.Count > 0)
|
||||
return TeamState.Working;
|
||||
|
||||
return lastRunStatus switch
|
||||
{
|
||||
nameof(AgentRunStatus.BudgetExceeded) => TeamState.BudgetExhausted,
|
||||
nameof(AgentRunStatus.Failed)
|
||||
or nameof(AgentRunStatus.LoopLimitExceeded)
|
||||
or nameof(AgentRunStatus.TargetBusy) => TeamState.Problem,
|
||||
_ => TeamState.Idle
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The agent's typical run duration — the median of its recent runs.</summary>
|
||||
private async Task<TimeSpan> TypicalRunAsync(string agentId, CancellationToken ct)
|
||||
{
|
||||
if (usage is null)
|
||||
return TimeSpan.Zero;
|
||||
|
||||
var recent = await usage.GetRecentForAgentAsync(agentId, 20, ct);
|
||||
var durations = recent.Where(r => r.DurationMs > 0).Select(r => r.DurationMs).Order().ToList();
|
||||
return durations.Count == 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(durations[durations.Count / 2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using ClawdDotNet.Core.Tools;
|
||||
|
||||
namespace ClawdDotNet.Core.Activity;
|
||||
|
||||
/// <summary>A run that is in progress right now.</summary>
|
||||
public sealed record ActiveRun(
|
||||
string RunId,
|
||||
string AgentId,
|
||||
string Source,
|
||||
string? TaskId,
|
||||
string? CalledBy,
|
||||
DateTime StartedAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// One agent reaching another — for now a delegated run (<see cref="Kind"/> =
|
||||
/// <c>delegation</c>). Meetings and escalations will add their own kinds.
|
||||
/// </summary>
|
||||
public sealed record AgentLink(string From, string To, string Kind, DateTime AtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// #711: Who is working right now, and who reached whom — the live side of the team overview.
|
||||
///
|
||||
/// <para><b>Why a component of its own.</b> The engine already tracked running chats, but
|
||||
/// only those that went through <c>ChatAsync</c>; a fresh run (<c>@new</c>) was invisible to
|
||||
/// <c>IsRunning</c>. And nothing recorded that agent A had called agent B — the delegation
|
||||
/// chain knew it for the length of the call and then forgot. Here both are kept and
|
||||
/// announced, independent of which engine path a run takes.</para>
|
||||
///
|
||||
/// <para><b>Memory only, and bounded.</b> This is the view of "now". What happened
|
||||
/// yesterday is in the audit log; the time line (#711, later) replays it from there.</para>
|
||||
/// </summary>
|
||||
public sealed class AgentActivity
|
||||
{
|
||||
/// <summary>How many recent links are kept for a page that connects late.</summary>
|
||||
public const int RecentLinkCapacity = 500;
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<string, ActiveRun> _active = new(StringComparer.Ordinal);
|
||||
private readonly Queue<AgentLink> _recentLinks = new();
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public AgentActivity(TimeProvider? clock = null) => _clock = clock ?? TimeProvider.System;
|
||||
|
||||
public event Action<ActiveRun>? RunStarted;
|
||||
|
||||
/// <summary>A run ended — with the <c>AgentRunStatus</c> name, or <c>Aborted</c>.</summary>
|
||||
public event Action<ActiveRun, string>? RunFinished;
|
||||
|
||||
public event Action<AgentLink>? LinkObserved;
|
||||
|
||||
/// <summary>
|
||||
/// Announces a run. Dispose the handle when it ends; call <see cref="RunHandle.Complete"/>
|
||||
/// first to report how. A delegated run (<paramref name="chain"/> deeper than its root)
|
||||
/// also records the link from the caller.
|
||||
/// </summary>
|
||||
public RunHandle Begin(string runId, string agentId, string source, string? taskId, DelegationChain? chain)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var calledBy = chain is { Depth: > 0 } ? chain.Agents[^2] : null;
|
||||
var run = new ActiveRun(runId, agentId, source, taskId, calledBy, now);
|
||||
|
||||
AgentLink? link = calledBy is null ? null : new AgentLink(calledBy, agentId, "delegation", now);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_active[runId] = run;
|
||||
if (link is not null)
|
||||
{
|
||||
_recentLinks.Enqueue(link);
|
||||
while (_recentLinks.Count > RecentLinkCapacity)
|
||||
_recentLinks.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
Raise(() => RunStarted?.Invoke(run));
|
||||
if (link is not null)
|
||||
Raise(() => LinkObserved?.Invoke(link));
|
||||
|
||||
return new RunHandle(this, run);
|
||||
}
|
||||
|
||||
/// <summary>The runs in progress right now.</summary>
|
||||
public IReadOnlyList<ActiveRun> ActiveRuns()
|
||||
{
|
||||
lock (_lock)
|
||||
return [.. _active.Values];
|
||||
}
|
||||
|
||||
/// <summary>Links observed within <paramref name="window"/>, oldest first.</summary>
|
||||
public IReadOnlyList<AgentLink> RecentLinks(TimeSpan window)
|
||||
{
|
||||
var since = _clock.GetUtcNow().UtcDateTime - window;
|
||||
lock (_lock)
|
||||
return [.. _recentLinks.Where(l => l.AtUtc >= since)];
|
||||
}
|
||||
|
||||
private void End(ActiveRun run, string status)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_active.Remove(run.RunId))
|
||||
return;
|
||||
}
|
||||
|
||||
Raise(() => RunFinished?.Invoke(run, status));
|
||||
}
|
||||
|
||||
/// <summary>A subscriber that throws must not break the run it is watching.</summary>
|
||||
private static void Raise(Action raise)
|
||||
{
|
||||
try { raise(); }
|
||||
catch { /* observers are best effort */ }
|
||||
}
|
||||
|
||||
public sealed class RunHandle : IDisposable
|
||||
{
|
||||
private readonly AgentActivity _owner;
|
||||
private readonly ActiveRun _run;
|
||||
private string _status = "Aborted";
|
||||
|
||||
internal RunHandle(AgentActivity owner, ActiveRun run)
|
||||
{
|
||||
_owner = owner;
|
||||
_run = run;
|
||||
}
|
||||
|
||||
/// <summary>How the run ended. Without it, disposal reports <c>Aborted</c>.</summary>
|
||||
public void Complete(string status) => _status = status;
|
||||
|
||||
public void Dispose() => _owner.End(_run, _status);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
/// </summary>
|
||||
public BudgetGuard? BudgetGuard => _budgetGuard;
|
||||
|
||||
/// <summary>#711: Who is working right now and who reached whom — for the team overview.</summary>
|
||||
public Activity.AgentActivity Activity { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
|
||||
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
|
||||
@@ -179,8 +182,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
|
||||
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
|
||||
var recorder = CreateRecorder(agentConfig);
|
||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
|
||||
activity.Complete(result.Status.ToString());
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
@@ -498,8 +503,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
try
|
||||
{
|
||||
// #711: "working" starts past the gate — waiting for it is not working.
|
||||
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
|
||||
var recorder = CreateRecorder(agentConfig);
|
||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
|
||||
activity.Complete(result.Status.ToString());
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
|
||||
@@ -1721,3 +1721,77 @@ div.klein,
|
||||
td.klein {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ─── Team overview (#711) ─── */
|
||||
|
||||
.team-ansicht {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.team-zusammenfassung {
|
||||
flex-wrap: wrap;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.team-flaeche {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.team-graph {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 420px;
|
||||
background: var(--surface-subtil);
|
||||
border: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.team-seitenleiste {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.team-seitenleiste-kopf {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.team-seitenleiste h3 {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.team-ergebnis {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--schrift-mono);
|
||||
}
|
||||
|
||||
.team-aktionen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.team-legende {
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.team-flaeche { flex-direction: column; }
|
||||
.team-seitenleiste { width: auto; }
|
||||
}
|
||||
|
||||
@@ -35,10 +35,12 @@ import { renderUsageRuns } from "./views/usage-runs.js";
|
||||
import { renderUsageTimeline } from "./views/usage-timeline.js";
|
||||
import { renderRunDetail } from "./views/run-detail.js";
|
||||
import { renderPromptHistory } from "./views/prompt-history.js";
|
||||
import { renderTeam, leaveTeam } from "./views/team.js";
|
||||
|
||||
// ─── 1. Routen registrieren ───
|
||||
|
||||
function verlasseAnsicht() {
|
||||
leaveTeam();
|
||||
trenneLogStream();
|
||||
trenneAgentenChatsStream();
|
||||
stoppeInstanzenPolling();
|
||||
@@ -50,6 +52,7 @@ registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereCha
|
||||
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
|
||||
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
|
||||
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
|
||||
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
|
||||
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
|
||||
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
|
||||
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
|
||||
|
||||
@@ -67,6 +67,28 @@
|
||||
"nav.chat": "Chat",
|
||||
"nav.agents": "Agenten",
|
||||
"nav.tasks": "Aufgaben",
|
||||
"nav.team": "Teamübersicht",
|
||||
"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.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)",
|
||||
"team.state.Idle": "bereit",
|
||||
"team.state.Working": "arbeitet",
|
||||
"team.state.Stuck": "hängt",
|
||||
"team.state.WaitingForApproval": "wartet auf Freigabe",
|
||||
"team.state.Problem": "Problem",
|
||||
"team.state.BudgetExhausted": "Budget erschöpft",
|
||||
"team.running": "Läuft gerade",
|
||||
"team.since": "seit {time}",
|
||||
"team.task": "Aufgabe {id}",
|
||||
"team.calledBy": "gerufen von {agent}",
|
||||
"team.stuckHint": "Läufe dieses Agenten dauern sonst etwa {minutes} Min.",
|
||||
"team.approvals": "{n} Werkzeugaufruf/-aufrufe warten auf Freigabe",
|
||||
"team.lastRun": "Letzter Lauf",
|
||||
"team.openApprovals": "Freigaben öffnen",
|
||||
"team.openChat": "Chat öffnen",
|
||||
"team.openRuns": "Läufe anzeigen",
|
||||
"nav.services": "Dienste",
|
||||
"nav.group.analytics": "Analyse",
|
||||
"nav.usage": "Tokennutzung",
|
||||
|
||||
@@ -67,6 +67,28 @@
|
||||
"nav.chat": "Chat",
|
||||
"nav.agents": "Agents",
|
||||
"nav.tasks": "Tasks",
|
||||
"nav.team": "Team overview",
|
||||
"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.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)",
|
||||
"team.state.Idle": "idle",
|
||||
"team.state.Working": "working",
|
||||
"team.state.Stuck": "stuck",
|
||||
"team.state.WaitingForApproval": "waiting for approval",
|
||||
"team.state.Problem": "problem",
|
||||
"team.state.BudgetExhausted": "budget exhausted",
|
||||
"team.running": "Running now",
|
||||
"team.since": "since {time}",
|
||||
"team.task": "task {id}",
|
||||
"team.calledBy": "called by {agent}",
|
||||
"team.stuckHint": "Runs of this agent usually take about {minutes} min.",
|
||||
"team.approvals": "{n} tool call(s) waiting for approval",
|
||||
"team.lastRun": "Last run",
|
||||
"team.openApprovals": "Open approvals",
|
||||
"team.openChat": "Open chat",
|
||||
"team.openRuns": "Show runs",
|
||||
"nav.services": "Services",
|
||||
"nav.group.analytics": "Analytics",
|
||||
"nav.usage": "Token Usage",
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
</svg>
|
||||
<span class="nav-label" data-i18n="nav.agents">Agents</span>
|
||||
</a>
|
||||
<a href="#/team" class="nav-link" title="Team overview" data-i18n-title="nav.team">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M3 3h6v6H3z M15 3h6v6h-6z M9 15h6v6H9z M6 9v3h12V9 M12 12v3"/>
|
||||
</svg>
|
||||
<span class="nav-label" data-i18n="nav.team">Team overview</span>
|
||||
</a>
|
||||
<a href="#/aufgaben" class="nav-link" title="Tasks" data-i18n-title="nav.tasks">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||
|
||||
@@ -93,7 +93,7 @@ export function verbindeStrom() {
|
||||
meldeStatus(false);
|
||||
};
|
||||
|
||||
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
|
||||
const bekannteEreignisse = ["chat", "step", "run", "instance", "status", "activity"];
|
||||
for (const art of bekannteEreignisse) {
|
||||
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Vendored libraries
|
||||
|
||||
Third-party files the WebUI ships as-is. They live here instead of being loaded from a CDN
|
||||
because the target machine may have no internet access (WebUI-Leitfaden). Do not edit them;
|
||||
to update, replace the file and this table together.
|
||||
|
||||
Downloaded 2026-09-24 from the npm registry. Each tarball was checked against the
|
||||
`dist.integrity` (SHA-512) the registry publishes before extracting.
|
||||
|
||||
| Package | Version | File | License | SHA-256 of the file |
|
||||
|---|---|---|---|---|
|
||||
| [cytoscape](https://www.npmjs.com/package/cytoscape) | 3.34.3 | `cytoscape/cytoscape.min.js` | MIT | `5f3b5b529546d5af1fc5628590af033b74511a5b6f789f5f4682845863228b91` |
|
||||
| [elkjs](https://www.npmjs.com/package/elkjs) | 0.9.3 | `elkjs/elk.bundled.js` | EPL-2.0 | `b0745abd7f23cd91690a1587e377edbe19fd7233c783300290936720546216d4` |
|
||||
| [cytoscape-elk](https://www.npmjs.com/package/cytoscape-elk) | 2.3.0 | `cytoscape-elk/cytoscape-elk.js` | MIT | `25acbb9dea9b0dc6fef0e79b697547ee56e579f0e1c22ae4353131e72c7e140a` |
|
||||
|
||||
**Why elkjs 0.9.3 and not the newest.** cytoscape-elk 2.3.0 declares `elkjs ^0.9.3`. A newer
|
||||
elkjs would be untested with the adapter.
|
||||
|
||||
**How they are loaded.** All three are UMD bundles and, loaded as classic scripts in this
|
||||
order, register the globals `cytoscape`, `ELK` and `cytoscapeElk`. The team overview (#711)
|
||||
loads them on demand, so pages that do not draw a graph do not pay for ~2 MB of script.
|
||||
|
||||
The license texts sit next to each file.
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
|
||||
Copyright (c) 2016-2018, 2020, The Cytoscape Consortium.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the “Software”), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,356 @@
|
||||
(function webpackUniversalModuleDefinition(root, factory) {
|
||||
if(typeof exports === 'object' && typeof module === 'object')
|
||||
module.exports = factory(require("elkjs/lib/elk.bundled.js"));
|
||||
else if(typeof define === 'function' && define.amd)
|
||||
define(["elkjs"], factory);
|
||||
else if(typeof exports === 'object')
|
||||
exports["cytoscapeElk"] = factory(require("elkjs/lib/elk.bundled.js"));
|
||||
else
|
||||
root["cytoscapeElk"] = factory(root["ELK"]);
|
||||
})(this, function(__WEBPACK_EXTERNAL_MODULE__632__) {
|
||||
return /******/ (function() { // webpackBootstrap
|
||||
/******/ "use strict";
|
||||
/******/ var __webpack_modules__ = ({
|
||||
|
||||
/***/ 632:
|
||||
/***/ (function(module) {
|
||||
|
||||
module.exports = __WEBPACK_EXTERNAL_MODULE__632__;
|
||||
|
||||
/***/ })
|
||||
|
||||
/******/ });
|
||||
/************************************************************************/
|
||||
/******/ // The module cache
|
||||
/******/ var __webpack_module_cache__ = {};
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/ // Check if module is in cache
|
||||
/******/ var cachedModule = __webpack_module_cache__[moduleId];
|
||||
/******/ if (cachedModule !== undefined) {
|
||||
/******/ return cachedModule.exports;
|
||||
/******/ }
|
||||
/******/ // Create a new module (and put it into the cache)
|
||||
/******/ var module = __webpack_module_cache__[moduleId] = {
|
||||
/******/ // no module.id needed
|
||||
/******/ // no module.loaded needed
|
||||
/******/ exports: {}
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Execute the module function
|
||||
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
|
||||
/******/
|
||||
/******/ // Return the exports of the module
|
||||
/******/ return module.exports;
|
||||
/******/ }
|
||||
/******/
|
||||
/************************************************************************/
|
||||
/******/ /* webpack/runtime/compat get default export */
|
||||
/******/ !function() {
|
||||
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||
/******/ __webpack_require__.n = function(module) {
|
||||
/******/ var getter = module && module.__esModule ?
|
||||
/******/ function() { return module['default']; } :
|
||||
/******/ function() { return module; };
|
||||
/******/ __webpack_require__.d(getter, { a: getter });
|
||||
/******/ return getter;
|
||||
/******/ };
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/define property getters */
|
||||
/******/ !function() {
|
||||
/******/ // define getter functions for harmony exports
|
||||
/******/ __webpack_require__.d = function(exports, definition) {
|
||||
/******/ for(var key in definition) {
|
||||
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
|
||||
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }
|
||||
/******/ }();
|
||||
/******/
|
||||
/************************************************************************/
|
||||
var __webpack_exports__ = {};
|
||||
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
|
||||
!function() {
|
||||
|
||||
// EXPORTS
|
||||
__webpack_require__.d(__webpack_exports__, {
|
||||
"default": function() { return /* binding */ src; }
|
||||
});
|
||||
|
||||
// EXTERNAL MODULE: external {"commonjs":"elkjs/lib/elk.bundled.js","commonjs2":"elkjs/lib/elk.bundled.js","amd":"elkjs","root":"ELK"}
|
||||
var elk_bundled_js_amd_elkjs_root_ELK_ = __webpack_require__(632);
|
||||
var elk_bundled_js_amd_elkjs_root_ELK_default = /*#__PURE__*/__webpack_require__.n(elk_bundled_js_amd_elkjs_root_ELK_);
|
||||
;// CONCATENATED MODULE: ./src/assign.js
|
||||
// Simple, internal Object.assign() polyfill for options objects etc.
|
||||
function assign_assign(tgt) {
|
||||
for (var _len = arguments.length, srcs = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
|
||||
srcs[_key - 1] = arguments[_key];
|
||||
}
|
||||
|
||||
srcs.forEach(function (src) {
|
||||
Object.keys(src).forEach(function (k) {
|
||||
return tgt[k] = src[k];
|
||||
});
|
||||
});
|
||||
return tgt;
|
||||
}
|
||||
|
||||
/* harmony default export */ var src_assign = (Object.assign != null ? Object.assign.bind(Object) : assign_assign);
|
||||
;// CONCATENATED MODULE: ./src/defaults.js
|
||||
var defaults = {
|
||||
nodeDimensionsIncludeLabels: false,
|
||||
// Boolean which changes whether label dimensions are included when calculating node dimensions
|
||||
fit: true,
|
||||
// Whether to fit
|
||||
padding: 20,
|
||||
// Padding on fit
|
||||
animate: false,
|
||||
// Whether to transition the node positions
|
||||
animateFilter: function animateFilter() {
|
||||
return true;
|
||||
},
|
||||
// Whether to animate specific nodes when animation is on; non-animated nodes immediately go to their final positions
|
||||
animationDuration: 500,
|
||||
// Duration of animation in ms if enabled
|
||||
animationEasing: undefined,
|
||||
// Easing of animation if enabled
|
||||
transform: function transform(node, pos) {
|
||||
return pos;
|
||||
},
|
||||
// A function that applies a transform to the final node position
|
||||
ready: undefined,
|
||||
// Callback on layoutready
|
||||
stop: undefined,
|
||||
// Callback on layoutstop
|
||||
nodeLayoutOptions: undefined,
|
||||
// Special options for only the nodes
|
||||
elk: {
|
||||
// Options to pass directly to ELK `layoutOptions`. The subsequent identifier has to be used as property key in quotes.
|
||||
// E.g. for 'org.eclipse.elk.direction' use:
|
||||
// 'elk.direction'
|
||||
// Primary/mandatory, the elk algorithm to use
|
||||
// one of 'box', 'disco', 'force', 'layered', 'mrtree', 'radial', 'random', 'stress'
|
||||
// (see https://www.eclipse.org/elk/reference/algorithms.html)
|
||||
algorithm: undefined
|
||||
},
|
||||
priority: function priority() {
|
||||
return null;
|
||||
} // Edges with a non-nil value are skipped when geedy edge cycle breaking is enabled
|
||||
|
||||
};
|
||||
/* harmony default export */ var src_defaults = (defaults);
|
||||
;// CONCATENATED MODULE: ./src/layout.js
|
||||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
|
||||
|
||||
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
|
||||
|
||||
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
|
||||
|
||||
|
||||
|
||||
|
||||
var elkOverrides = {};
|
||||
|
||||
var getPos = function getPos(ele, options) {
|
||||
var dims = ele.layoutDimensions(options);
|
||||
var parent = ele.parent();
|
||||
var k = ele.scratch('elk');
|
||||
var p = {
|
||||
x: k.x,
|
||||
y: k.y
|
||||
};
|
||||
|
||||
while (parent.nonempty()) {
|
||||
var kp = parent.scratch('elk');
|
||||
p.x += kp.x;
|
||||
p.y += kp.y;
|
||||
parent = parent.parent();
|
||||
} // elk considers a node position to be its top-left corner, while cy is the centre
|
||||
|
||||
|
||||
p.x += dims.w / 2;
|
||||
p.y += dims.h / 2;
|
||||
return p;
|
||||
};
|
||||
|
||||
var makeNode = function makeNode(node, options) {
|
||||
var k = {
|
||||
_cyEle: node,
|
||||
id: node.id()
|
||||
}; // Apply nodeLayoutOptions per user-specified function
|
||||
// e.g. nodeLayoutOptions => n.scratch('layoutOptions')
|
||||
|
||||
if (options.nodeLayoutOptions) {
|
||||
k.layoutOptions = options.nodeLayoutOptions(node);
|
||||
}
|
||||
|
||||
if (!node.isParent()) {
|
||||
var dims = node.layoutDimensions(options);
|
||||
var p = node.position(); // the elk position is the top-left corner, cy is the centre
|
||||
|
||||
k.x = p.x - dims.w / 2;
|
||||
k.y = p.y - dims.h / 2;
|
||||
k.width = dims.w;
|
||||
k.height = dims.h;
|
||||
}
|
||||
|
||||
node.scratch('elk', k);
|
||||
return k;
|
||||
};
|
||||
|
||||
var makeEdge = function makeEdge(edge
|
||||
/*, options*/
|
||||
) {
|
||||
var k = {
|
||||
_cyEle: edge,
|
||||
id: edge.id(),
|
||||
source: edge.data('source'),
|
||||
target: edge.data('target')
|
||||
};
|
||||
edge.scratch('elk', k);
|
||||
return k;
|
||||
};
|
||||
|
||||
var makeGraph = function makeGraph(nodes, edges, options) {
|
||||
var elkNodes = [];
|
||||
var elkEdges = [];
|
||||
var elkEleLookup = {};
|
||||
var graph = {
|
||||
id: 'root',
|
||||
children: [],
|
||||
edges: []
|
||||
}; // map all nodes
|
||||
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i];
|
||||
var k = makeNode(n, options);
|
||||
elkNodes.push(k);
|
||||
elkEleLookup[n.id()] = k;
|
||||
} // map all edges
|
||||
|
||||
|
||||
for (var _i = 0; _i < edges.length; _i++) {
|
||||
var e = edges[_i];
|
||||
|
||||
var _k = makeEdge(e, options);
|
||||
|
||||
elkEdges.push(_k);
|
||||
elkEleLookup[e.id()] = _k;
|
||||
} // make hierarchy
|
||||
|
||||
|
||||
for (var _i2 = 0; _i2 < elkNodes.length; _i2++) {
|
||||
var _k2 = elkNodes[_i2];
|
||||
var _n = _k2._cyEle;
|
||||
|
||||
if (!_n.isChild()) {
|
||||
graph.children.push(_k2);
|
||||
} else {
|
||||
var parent = _n.parent();
|
||||
|
||||
var parentK = elkEleLookup[parent.id()];
|
||||
var children = parentK.children = parentK.children || [];
|
||||
children.push(_k2);
|
||||
}
|
||||
}
|
||||
|
||||
for (var _i3 = 0; _i3 < elkEdges.length; _i3++) {
|
||||
var _k3 = elkEdges[_i3]; // put all edges in the top level for now
|
||||
// TODO does this cause issues in certain edgecases?
|
||||
|
||||
/*let e = k._cyEle;
|
||||
let parentSrc = e.source().parent();
|
||||
let parentTgt = e.target().parent();
|
||||
if ( false && parentSrc.nonempty() && parentTgt.nonempty() && parentSrc.same( parentTgt ) ){
|
||||
let kp = elkEleLookup[ parentSrc.id() ];
|
||||
kp.edges = kp.edges || [];
|
||||
kp.edges.push( k );
|
||||
} else {*/
|
||||
|
||||
graph.edges.push(_k3); //}
|
||||
}
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
var Layout = /*#__PURE__*/function () {
|
||||
function Layout(options) {
|
||||
_classCallCheck(this, Layout);
|
||||
|
||||
var elkOptions = options.elk;
|
||||
var cy = options.cy;
|
||||
this.options = src_assign({}, src_defaults, options);
|
||||
this.options.elk = src_assign({
|
||||
aspectRatio: cy.width() / cy.height()
|
||||
}, src_defaults.elk, elkOptions, elkOverrides);
|
||||
}
|
||||
|
||||
_createClass(Layout, [{
|
||||
key: "run",
|
||||
value: function run() {
|
||||
var layout = this;
|
||||
var options = this.options;
|
||||
var eles = options.eles;
|
||||
var nodes = eles.nodes();
|
||||
var edges = eles.edges();
|
||||
var elk = new (elk_bundled_js_amd_elkjs_root_ELK_default())();
|
||||
var graph = makeGraph(nodes, edges, options);
|
||||
graph['layoutOptions'] = options.elk;
|
||||
elk.layout(graph).then(function () {
|
||||
nodes.filter(function (n) {
|
||||
return !n.isParent();
|
||||
}).layoutPositions(layout, options, function (n) {
|
||||
return getPos(n, options);
|
||||
});
|
||||
});
|
||||
return this;
|
||||
}
|
||||
}, {
|
||||
key: "stop",
|
||||
value: function stop() {
|
||||
return this; // chaining
|
||||
}
|
||||
}, {
|
||||
key: "destroy",
|
||||
value: function destroy() {
|
||||
return this; // chaining
|
||||
}
|
||||
}]);
|
||||
|
||||
return Layout;
|
||||
}();
|
||||
|
||||
/* harmony default export */ var layout = (Layout);
|
||||
;// CONCATENATED MODULE: ./src/index.js
|
||||
// registers the extension on a cytoscape lib ref
|
||||
|
||||
var register = function register(cytoscape) {
|
||||
if (!cytoscape) {
|
||||
return;
|
||||
} // can't register if cytoscape unspecified
|
||||
|
||||
|
||||
cytoscape('layout', 'elk', layout); // register with cytoscape.js
|
||||
};
|
||||
|
||||
if (typeof cytoscape !== 'undefined') {
|
||||
// expose to global cytoscape (i.e. window.cytoscape)
|
||||
// eslint-disable-next-line no-undef
|
||||
register(cytoscape);
|
||||
}
|
||||
|
||||
/* harmony default export */ var src = (register);
|
||||
}();
|
||||
__webpack_exports__ = __webpack_exports__["default"];
|
||||
/******/ return __webpack_exports__;
|
||||
/******/ })()
|
||||
;
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2016-2026, The Cytoscape Consortium.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the “Software”), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,264 @@
|
||||
# Eclipse Public License - v 2.0
|
||||
|
||||
THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
|
||||
PUBLIC LICENSE (“AGREEMENT”). ANY USE, REPRODUCTION OR DISTRIBUTION OF
|
||||
THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
|
||||
|
||||
## 1. DEFINITIONS
|
||||
|
||||
“Contribution” means:
|
||||
|
||||
- a\) in the case of the initial Contributor, the initial content
|
||||
Distributed under this Agreement, and
|
||||
- b\) in the case of each subsequent Contributor:
|
||||
- i\) changes to the Program, and
|
||||
- ii\) additions to the Program;
|
||||
|
||||
where such changes and/or additions to the Program originate from
|
||||
and are Distributed by that particular Contributor. A Contribution
|
||||
“originates” from a Contributor if it was added to the Program by
|
||||
such Contributor itself or anyone acting on such Contributor's
|
||||
behalf. Contributions do not include changes or additions to the
|
||||
Program that are not Modified Works.
|
||||
|
||||
“Contributor” means any person or entity that Distributes the Program.
|
||||
|
||||
“Licensed Patents” mean patent claims licensable by a Contributor which
|
||||
are necessarily infringed by the use or sale of its Contribution alone
|
||||
or when combined with the Program.
|
||||
|
||||
“Program” means the Contributions Distributed in accordance with this
|
||||
Agreement.
|
||||
|
||||
“Recipient” means anyone who receives the Program under this Agreement
|
||||
or any Secondary License (as applicable), including Contributors.
|
||||
|
||||
“Derivative Works” shall mean any work, whether in Source Code or other
|
||||
form, that is based on (or derived from) the Program and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship.
|
||||
|
||||
“Modified Works” shall mean any work in Source Code or other form that
|
||||
results from an addition to, deletion from, or modification of the
|
||||
contents of the Program, including, for purposes of clarity any new file
|
||||
in Source Code form that contains any contents of the Program. Modified
|
||||
Works shall not include works that contain only declarations,
|
||||
interfaces, types, classes, structures, or files of the Program solely
|
||||
in each case in order to link to, bind by name, or subclass the Program
|
||||
or Modified Works thereof.
|
||||
|
||||
“Distribute” means the acts of a) distributing or b) making available in
|
||||
any manner that enables the transfer of a copy.
|
||||
|
||||
“Source Code” means the form of a Program preferred for making
|
||||
modifications, including but not limited to software source code,
|
||||
documentation source, and configuration files.
|
||||
|
||||
“Secondary License” means either the GNU General Public License, Version
|
||||
2.0, or any later versions of that license, including any exceptions or
|
||||
additional permissions as identified by the initial Contributor.
|
||||
|
||||
## 2. GRANT OF RIGHTS
|
||||
|
||||
- a\) Subject to the terms of this Agreement, each Contributor hereby
|
||||
grants Recipient a non-exclusive, worldwide, royalty-free copyright
|
||||
license to reproduce, prepare Derivative Works of, publicly display,
|
||||
publicly perform, Distribute and sublicense the Contribution of such
|
||||
Contributor, if any, and such Derivative Works.
|
||||
- b\) Subject to the terms of this Agreement, each Contributor hereby
|
||||
grants Recipient a non-exclusive, worldwide, royalty-free patent license
|
||||
under Licensed Patents to make, use, sell, offer to sell, import and
|
||||
otherwise transfer the Contribution of such Contributor, if any, in
|
||||
Source Code or other form. This patent license shall apply to the
|
||||
combination of the Contribution and the Program if, at the time the
|
||||
Contribution is added by the Contributor, such addition of the
|
||||
Contribution causes such combination to be covered by the
|
||||
Licensed Patents. The patent license shall not apply to any other
|
||||
combinations which include the Contribution. No hardware per se is
|
||||
licensed hereunder.
|
||||
- c\) Recipient understands that although each Contributor grants the
|
||||
licenses to its Contributions set forth herein, no assurances are
|
||||
provided by any Contributor that the Program does not infringe the
|
||||
patent or other intellectual property rights of any other entity. Each
|
||||
Contributor disclaims any liability to Recipient for claims brought by
|
||||
any other entity based on infringement of intellectual property rights
|
||||
or otherwise. As a condition to exercising the rights and licenses
|
||||
granted hereunder, each Recipient hereby assumes sole responsibility to
|
||||
secure any other intellectual property rights needed, if any. For
|
||||
example, if a third party patent license is required to allow Recipient
|
||||
to Distribute the Program, it is Recipient's responsibility to acquire
|
||||
that license before distributing the Program.
|
||||
- d\) Each Contributor represents that to its knowledge it has sufficient
|
||||
copyright rights in its Contribution, if any, to grant the copyright
|
||||
license set forth in this Agreement.
|
||||
- e\) Notwithstanding the terms of any Secondary License, no Contributor
|
||||
makes additional grants to any Recipient (other than those set forth in
|
||||
this Agreement) as a result of such Recipient's receipt of the Program
|
||||
under the terms of a Secondary License (if permitted under the terms of
|
||||
Section 3).
|
||||
|
||||
## 3. REQUIREMENTS
|
||||
|
||||
3.1 If a Contributor Distributes the Program in any form, then:
|
||||
|
||||
- a\) the Program must also be made available as Source Code, in accordance
|
||||
with section 3.2, and the Contributor must accompany the Program with a
|
||||
statement that the Source Code for the Program is available under this
|
||||
Agreement, and informs Recipients how to obtain it in a reasonable
|
||||
manner on or through a medium customarily used for software exchange;
|
||||
and
|
||||
- b\) the Contributor may Distribute the Program under a license different
|
||||
than this Agreement, provided that such license:
|
||||
- i\) effectively disclaims on behalf of all other Contributors all
|
||||
warranties and conditions, express and implied, including warranties or
|
||||
conditions of title and non-infringement, and implied warranties or
|
||||
conditions of merchantability and fitness for a particular purpose;
|
||||
- ii\) effectively excludes on behalf of all other Contributors all
|
||||
liability for damages, including direct, indirect, special, incidental
|
||||
and consequential damages, such as lost profits;
|
||||
- iii\) does not attempt to limit or alter the recipients' rights in the
|
||||
Source Code under section 3.2; and
|
||||
- iv\) requires any subsequent distribution of the Program by any party to
|
||||
be under a license that satisfies the requirements of this section 3.
|
||||
|
||||
3.2 When the Program is Distributed as Source Code:
|
||||
|
||||
- a\) it must be made available under this Agreement, or if the Program (i)
|
||||
is combined with other material in a separate file or files made
|
||||
available under a Secondary License, and (ii) the initial Contributor
|
||||
attached to the Source Code the notice described in Exhibit A of this
|
||||
Agreement, then the Program may be made available under the terms of
|
||||
such Secondary Licenses, and
|
||||
- b\) a copy of this Agreement must be included with each copy of
|
||||
the Program.
|
||||
|
||||
3.3 Contributors may not remove or alter any copyright, patent,
|
||||
trademark, attribution notices, disclaimers of warranty, or limitations
|
||||
of liability (‘notices’) contained within the Program from any copy of
|
||||
the Program which they Distribute, provided that Contributors may add
|
||||
their own appropriate notices.
|
||||
|
||||
## 4. COMMERCIAL DISTRIBUTION
|
||||
|
||||
Commercial distributors of software may accept certain responsibilities
|
||||
with respect to end users, business partners and the like. While this
|
||||
license is intended to facilitate the commercial use of the Program, the
|
||||
Contributor who includes the Program in a commercial product offering
|
||||
should do so in a manner which does not create potential liability for
|
||||
other Contributors. Therefore, if a Contributor includes the Program in
|
||||
a commercial product offering, such Contributor (“Commercial
|
||||
Contributor”) hereby agrees to defend and indemnify every other
|
||||
Contributor (“Indemnified Contributor”) against any losses, damages and
|
||||
costs (collectively “Losses”) arising from claims, lawsuits and other
|
||||
legal actions brought by a third party against the Indemnified
|
||||
Contributor to the extent caused by the acts or omissions of such
|
||||
Commercial Contributor in connection with its distribution of the
|
||||
Program in a commercial product offering. The obligations in this
|
||||
section do not apply to any claims or Losses relating to any actual or
|
||||
alleged intellectual property infringement. In order to qualify, an
|
||||
Indemnified Contributor must: a) promptly notify the Commercial
|
||||
Contributor in writing of such claim, and b) allow the Commercial
|
||||
Contributor to control, and cooperate with the Commercial Contributor
|
||||
in, the defense and any related settlement negotiations. The Indemnified
|
||||
Contributor may participate in any such claim at its own expense.
|
||||
|
||||
For example, a Contributor might include the Program in a commercial
|
||||
product offering, Product X. That Contributor is then a Commercial
|
||||
Contributor. If that Commercial Contributor then makes performance
|
||||
claims, or offers warranties related to Product X, those performance
|
||||
claims and warranties are such Commercial Contributor's responsibility
|
||||
alone. Under this section, the Commercial Contributor would have to
|
||||
defend claims against the other Contributors related to those
|
||||
performance claims and warranties, and if a court requires any other
|
||||
Contributor to pay any damages as a result, the Commercial Contributor
|
||||
must pay those damages.
|
||||
|
||||
## 5. NO WARRANTY {#warranty}
|
||||
|
||||
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
|
||||
PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN “AS IS”
|
||||
BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
|
||||
IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
|
||||
TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
|
||||
PURPOSE. Each Recipient is solely responsible for determining the
|
||||
appropriateness of using and distributing the Program and assumes all
|
||||
risks associated with its exercise of rights under this Agreement,
|
||||
including but not limited to the risks and costs of program errors,
|
||||
compliance with applicable laws, damage to or loss of data, programs or
|
||||
equipment, and unavailability or interruption of operations.
|
||||
|
||||
## 6. DISCLAIMER OF LIABILITY {#disclaimer}
|
||||
|
||||
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
|
||||
PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
|
||||
SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
|
||||
PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
|
||||
EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
## 7. GENERAL
|
||||
|
||||
If any provision of this Agreement is invalid or unenforceable under
|
||||
applicable law, it shall not affect the validity or enforceability of
|
||||
the remainder of the terms of this Agreement, and without further action
|
||||
by the parties hereto, such provision shall be reformed to the minimum
|
||||
extent necessary to make such provision valid and enforceable.
|
||||
|
||||
If Recipient institutes patent litigation against any entity (including
|
||||
a cross-claim or counterclaim in a lawsuit) alleging that the Program
|
||||
itself (excluding combinations of the Program with other software or
|
||||
hardware) infringes such Recipient's patent(s), then such Recipient's
|
||||
rights granted under Section 2(b) shall terminate as of the date such
|
||||
litigation is filed.
|
||||
|
||||
All Recipient's rights under this Agreement shall terminate if it fails
|
||||
to comply with any of the material terms or conditions of this Agreement
|
||||
and does not cure such failure in a reasonable period of time after
|
||||
becoming aware of such noncompliance. If all Recipient's rights under
|
||||
this Agreement terminate, Recipient agrees to cease use and distribution
|
||||
of the Program as soon as reasonably practicable. However, Recipient's
|
||||
obligations under this Agreement and any licenses granted by Recipient
|
||||
relating to the Program shall continue and survive.
|
||||
|
||||
Everyone is permitted to copy and distribute copies of this Agreement,
|
||||
but in order to avoid inconsistency the Agreement is copyrighted and may
|
||||
only be modified in the following manner. The Agreement Steward reserves
|
||||
the right to publish new versions (including revisions) of this
|
||||
Agreement from time to time. No one other than the Agreement Steward has
|
||||
the right to modify this Agreement. The Eclipse Foundation is the
|
||||
initial Agreement Steward. The Eclipse Foundation may assign the
|
||||
responsibility to serve as the Agreement Steward to a suitable separate
|
||||
entity. Each new version of the Agreement will be given a distinguishing
|
||||
version number. The Program (including Contributions) may always be
|
||||
Distributed subject to the version of the Agreement under which it was
|
||||
received. In addition, after a new version of the Agreement is
|
||||
published, Contributor may elect to Distribute the Program (including
|
||||
its Contributions) under the new version.
|
||||
|
||||
Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
|
||||
receives no rights or licenses to the intellectual property of any
|
||||
Contributor under this Agreement, whether expressly, by implication,
|
||||
estoppel or otherwise. All rights in the Program not expressly granted
|
||||
under this Agreement are reserved. Nothing in this Agreement is intended
|
||||
to be enforceable by any entity that is not a Contributor or Recipient.
|
||||
No third-party beneficiary rights are created under this Agreement.
|
||||
|
||||
## Exhibit A – Form of Secondary Licenses Notice {#exhibit-a}
|
||||
|
||||
“This Source Code may also be made available under the following
|
||||
Secondary Licenses when the conditions for such availability set forth
|
||||
in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
|
||||
version(s), and exceptions or additional permissions here}.”
|
||||
|
||||
> Simply including a copy of this Agreement, including this Exhibit A is
|
||||
> not sufficient to license the Source Code under Secondary Licenses.
|
||||
>
|
||||
> If it is not possible or desirable to put the notice in a particular
|
||||
> file, then You may include the notice in a location (such as a LICENSE
|
||||
> file in a relevant directory) where a recipient would be likely to
|
||||
> look for such a notice.
|
||||
>
|
||||
> You may add additional accurate notices of copyright ownership.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,429 @@
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// - A click opens a side panel, not the chat — most problems are solved by an approval.
|
||||
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
import { erstelleElement, erstellePlakette, formatiereDatumZeit } from "../modules/dom.js";
|
||||
import { abonnieren } from "../modules/events.js";
|
||||
import { t } from "../modules/i18n.js";
|
||||
|
||||
// Loaded on demand: pages without a graph do not pay for ~2 MB of script.
|
||||
const LIBRARIES = [
|
||||
"vendor/cytoscape/cytoscape.min.js",
|
||||
"vendor/elkjs/elk.bundled.js",
|
||||
"vendor/cytoscape-elk/cytoscape-elk.js"
|
||||
];
|
||||
|
||||
/** How long a link stays visible after it was observed; it fades out over this time. */
|
||||
const LINK_VISIBLE_MS = 30_000;
|
||||
|
||||
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
// Every state has a colour AND a symbol: colour alone is not enough.
|
||||
const STATES = {
|
||||
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
|
||||
Working: { symbol: "▶", tone: "erfolg", colour: "--accent" },
|
||||
Stuck: { symbol: "⧗", tone: "gefahr", colour: "--gefahr" },
|
||||
WaitingForApproval: { symbol: "✋", tone: "warnung", colour: "--warnung" },
|
||||
Problem: { symbol: "⚠", tone: "gefahr", colour: "--gefahr" },
|
||||
BudgetExhausted: { symbol: "$", tone: "warnung", colour: "--warnung" }
|
||||
};
|
||||
|
||||
let librariesLoaded = null;
|
||||
let cy = null;
|
||||
let snapshot = null;
|
||||
let selectedAgent = null;
|
||||
let panel = null;
|
||||
let summary = null;
|
||||
let unsubscribe = null;
|
||||
let refreshTimer = null;
|
||||
let fadeTimer = null;
|
||||
let refreshPending = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
|
||||
let layoutWidth = 0;
|
||||
|
||||
// ─── Loading ───
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error(`Could not load ${src}`));
|
||||
document.head.append(script);
|
||||
});
|
||||
}
|
||||
|
||||
function loadLibraries() {
|
||||
librariesLoaded ??= (async () => {
|
||||
// In order: cytoscape-elk registers against the globals of the two before it.
|
||||
for (const src of LIBRARIES) await loadScript(src);
|
||||
window.cytoscape.use(window.cytoscapeElk);
|
||||
})();
|
||||
return librariesLoaded;
|
||||
}
|
||||
|
||||
function token(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#888";
|
||||
}
|
||||
|
||||
// ─── View ───
|
||||
|
||||
export async function renderTeam() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
leaveTeam();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "team-ansicht" });
|
||||
layout.append(
|
||||
erstelleElement("h1", { text: t("team.title") }),
|
||||
erstelleElement("p", { klasse: "gedaempft", text: t("team.subtitle") })
|
||||
);
|
||||
|
||||
summary = erstelleElement("div", { klasse: "team-zusammenfassung gruppe-horizontal" });
|
||||
const canvas = erstelleElement("div", { klasse: "team-graph", id: "team-graph" });
|
||||
panel = erstelleElement("aside", { klasse: "team-seitenleiste" });
|
||||
panel.hidden = true;
|
||||
|
||||
layout.append(summary, erstelleElement("div", { klasse: "team-flaeche" }, canvas, panel), buildLegend());
|
||||
container.replaceChildren(layout);
|
||||
|
||||
if (!holeAktiveInstanz()) {
|
||||
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("team.noInstance") }));
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
|
||||
|
||||
try {
|
||||
await loadLibraries();
|
||||
snapshot = await api("/team");
|
||||
} catch (err) {
|
||||
canvas.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message }));
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.replaceChildren();
|
||||
drawGraph(canvas);
|
||||
|
||||
unsubscribe = abonnieren("activity", onActivity);
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS);
|
||||
fadeTimer = setInterval(fadeLinks, 1_000);
|
||||
}
|
||||
|
||||
/** Called when the route changes: stop listening, stop timers, free the graph. */
|
||||
export function leaveTeam() {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
clearInterval(refreshTimer);
|
||||
clearInterval(fadeTimer);
|
||||
clearTimeout(refreshPending);
|
||||
refreshTimer = fadeTimer = refreshPending = null;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
cy?.destroy();
|
||||
cy = null;
|
||||
selectedAgent = null;
|
||||
}
|
||||
|
||||
// ─── Graph ───
|
||||
|
||||
function drawGraph(canvas) {
|
||||
cy = window.cytoscape({
|
||||
container: canvas,
|
||||
elements: [],
|
||||
wheelSensitivity: 0.2,
|
||||
minZoom: 0.3,
|
||||
maxZoom: 2.5,
|
||||
// Nodes are not dragged: the layout comes from the structure, not the hand.
|
||||
autoungrabify: true,
|
||||
style: [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
shape: "rectangle",
|
||||
width: 190,
|
||||
height: 58,
|
||||
"background-color": token("--surface"),
|
||||
"border-width": 2,
|
||||
"border-color": "data(colour)",
|
||||
label: "data(label)",
|
||||
"text-wrap": "wrap",
|
||||
"text-max-width": 176,
|
||||
"text-valign": "center",
|
||||
"text-halign": "center",
|
||||
"font-family": "Barlow, sans-serif",
|
||||
"font-size": 13,
|
||||
color: token("--text")
|
||||
}
|
||||
},
|
||||
{ selector: "node.aktiv", style: { "border-width": 4 } },
|
||||
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 3,
|
||||
"line-color": token("--accent"),
|
||||
"target-arrow-color": token("--accent"),
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
opacity: "data(opacity)"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
cy.on("tap", "node", (event) => openPanel(event.target.id()));
|
||||
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
|
||||
|
||||
applySnapshot(true);
|
||||
for (const link of snapshot.links ?? []) addLink(link);
|
||||
observeSize(canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* The area can still be 0 px wide when the graph is first drawn — the page is being laid out
|
||||
* around it. ELK then packs everything into one column, and nothing can be fitted. So: lay
|
||||
* out again once the area has a width, and fit again whenever its size changes (the side
|
||||
* panel opening, the window resizing).
|
||||
*/
|
||||
function observeSize(canvas) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!cy) return;
|
||||
cy.resize();
|
||||
if (layoutWidth === 0 && canvas.clientWidth > 0) runLayout();
|
||||
else cy.fit(undefined, 30);
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function nodeLabel(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}`)}`;
|
||||
}
|
||||
|
||||
function applySnapshot(relayout) {
|
||||
if (!cy || !snapshot) return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
cy.nodes().filter((n) => !present.has(n.id())).remove();
|
||||
|
||||
if (relayout) runLayout();
|
||||
|
||||
renderSummary();
|
||||
if (selectedAgent) openPanel(selectedAgent);
|
||||
}
|
||||
|
||||
// ─── Live ───
|
||||
|
||||
function forActiveInstance(data) {
|
||||
// The manager merges the streams of all instances and stamps each event.
|
||||
return !data?.instanz || data.instanz === holeAktiveInstanz();
|
||||
}
|
||||
|
||||
function onActivity(data) {
|
||||
if (!cy || !forActiveInstance(data)) return;
|
||||
|
||||
if (data.kind === "link" && data.link) addLink(data.link);
|
||||
|
||||
// Start and end change states; ask the instance instead of guessing here.
|
||||
clearTimeout(refreshPending);
|
||||
refreshPending = setTimeout(refresh, 400);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!cy) return;
|
||||
try {
|
||||
snapshot = await api("/team");
|
||||
applySnapshot(false);
|
||||
} 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 fadeLinks() {
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Summary and legend ───
|
||||
|
||||
function renderSummary() {
|
||||
if (!summary || !snapshot) return;
|
||||
const counts = {};
|
||||
for (const member of snapshot.members) counts[member.state] = (counts[member.state] ?? 0) + 1;
|
||||
|
||||
const parts = Object.keys(STATES)
|
||||
.filter((state) => counts[state])
|
||||
.map((state) => erstellePlakette(`${STATES[state].symbol} ${counts[state]} ${t(`team.state.${state}`)}`, STATES[state].tone));
|
||||
|
||||
summary.replaceChildren(
|
||||
...(parts.length ? parts : [erstelleElement("span", { klasse: "gedaempft", text: t("team.empty") })])
|
||||
);
|
||||
}
|
||||
|
||||
function buildLegend() {
|
||||
return erstelleElement(
|
||||
"div",
|
||||
{ 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.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();
|
||||
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
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
|
||||
})),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
|
||||
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
|
||||
];
|
||||
|
||||
// Top layer: what is happening now.
|
||||
if (member.runs.length) {
|
||||
rows.push(erstelleElement("h3", { text: t("team.running") }));
|
||||
for (const run of member.runs) {
|
||||
const parts = [
|
||||
t("team.since", { time: formatiereDatumZeit(run.startedAtUtc) }),
|
||||
run.source,
|
||||
run.taskId ? t("team.task", { id: run.taskId }) : null,
|
||||
run.calledBy ? t("team.calledBy", { agent: run.calledBy }) : null
|
||||
].filter(Boolean);
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein", text: parts.join(" · ") }));
|
||||
}
|
||||
if (member.state === "Stuck" && member.typicalRunMs > 0) {
|
||||
rows.push(erstelleElement("p", {
|
||||
klasse: "text-klein",
|
||||
text: `⚠ ${t("team.stuckHint", { minutes: Math.max(1, Math.round(member.typicalRunMs / 60_000)) })}`
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (member.pendingApprovals > 0) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: member.pendingApprovals })}` }));
|
||||
}
|
||||
|
||||
// Middle layer: how the last run ended.
|
||||
rows.push(erstelleElement("h3", { text: t("team.lastRun") }));
|
||||
if (member.lastRunStatus) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein" },
|
||||
erstellePlakette(member.lastRunStatus, member.lastRunStatus === "Completed" ? "erfolg" : "warnung"),
|
||||
` ${formatiereDatumZeit(member.lastRunAtUtc)}`));
|
||||
if (member.lastRunResult) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein team-ergebnis", text: member.lastRunResult }));
|
||||
}
|
||||
} else {
|
||||
rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
|
||||
}
|
||||
|
||||
// The actions — the approval first, it is what most problems need.
|
||||
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
|
||||
if (member.pendingApprovals > 0) {
|
||||
actions.append(link(`#/staging`, t("team.openApprovals"), "primaer"));
|
||||
}
|
||||
actions.append(
|
||||
link(`#/chat/${encodeURIComponent(member.agentId)}`, t("team.openChat")),
|
||||
// Bottom layer: every run with its raw transcript.
|
||||
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
|
||||
);
|
||||
rows.push(actions);
|
||||
|
||||
panel.replaceChildren(...rows);
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function link(href, text, style = "flach") {
|
||||
return erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: `${style} klein`,
|
||||
text,
|
||||
onclick: () => { window.location.hash = href; }
|
||||
});
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
selectedAgent = null;
|
||||
cy?.elements().unselect();
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
@@ -125,6 +125,31 @@ 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: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
|
||||
/// WaitingForApproval, Problem, BudgetExhausted — decided by the instance, not the page.
|
||||
/// </summary>
|
||||
public sealed record TeamMemberDto(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
string Model,
|
||||
string State,
|
||||
IReadOnlyList<ActiveRunDto> Runs,
|
||||
int PendingApprovals,
|
||||
string? LastRunStatus,
|
||||
DateTime? LastRunAtUtc,
|
||||
string? LastRunResult,
|
||||
long TypicalRunMs);
|
||||
|
||||
/// <summary>#711: A run in progress.</summary>
|
||||
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
|
||||
|
||||
/// <summary>#711: One agent reaching another.</summary>
|
||||
public sealed record AgentLinkDto(string From, string To, string Kind, DateTime AtUtc);
|
||||
|
||||
/// <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);
|
||||
|
||||
|
||||
@@ -69,6 +69,21 @@ internal static class Mapping
|
||||
public static ChatEntryDto ToDto(this ChatEntry entry)
|
||||
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
|
||||
|
||||
public static TeamSnapshotDto ToDto(this App.Services.TeamSnapshot snapshot)
|
||||
=> new(
|
||||
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(),
|
||||
snapshot.RecentLinks.Select(ToDto).ToList(),
|
||||
snapshot.AtUtc);
|
||||
|
||||
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
|
||||
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
|
||||
|
||||
public static AgentLinkDto ToDto(this Core.Activity.AgentLink link)
|
||||
=> new(link.From, link.To, link.Kind, link.AtUtc);
|
||||
|
||||
public static TaskDto ToDto(this TaskOverview overview)
|
||||
=> overview.Task.ToDto() with
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ internal static class ApiEndpoints
|
||||
api.MapUsage();
|
||||
api.MapAnalytics();
|
||||
api.MapLogs();
|
||||
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
|
||||
|
||||
app.MapEvents();
|
||||
app.MapControl();
|
||||
|
||||
@@ -71,12 +71,23 @@ internal static class EventEndpoints
|
||||
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
|
||||
=> Publish("run", new
|
||||
{
|
||||
agentId = result.AgentId,
|
||||
model,
|
||||
status = result.Status.ToString(),
|
||||
steps = result.StepCount,
|
||||
tokens = result.TokensUsed
|
||||
});
|
||||
|
||||
// #711: the team overview — who starts, who stops, who reached whom.
|
||||
void OnRunStarted(Core.Activity.ActiveRun run)
|
||||
=> Publish("activity", new { kind = "started", run = run.ToDto() });
|
||||
|
||||
void OnRunFinished(Core.Activity.ActiveRun run, string status)
|
||||
=> Publish("activity", new { kind = "finished", run = run.ToDto(), status });
|
||||
|
||||
void OnLink(Core.Activity.AgentLink link)
|
||||
=> Publish("activity", new { kind = "link", link = link.ToDto() });
|
||||
|
||||
void OnInstanceChanged(InstanceChange change)
|
||||
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
|
||||
|
||||
@@ -88,6 +99,9 @@ internal static class EventEndpoints
|
||||
engine.OnChatEntryAdded += OnChat;
|
||||
engine.OnStepCompleted += OnStep;
|
||||
engine.OnRunCompleted += OnRunCompleted;
|
||||
engine.Activity.RunStarted += OnRunStarted;
|
||||
engine.Activity.RunFinished += OnRunFinished;
|
||||
engine.Activity.LinkObserved += OnLink;
|
||||
}
|
||||
|
||||
host.InstanceChanged += OnInstanceChanged;
|
||||
@@ -106,6 +120,9 @@ internal static class EventEndpoints
|
||||
e.OnChatEntryAdded -= OnChat;
|
||||
e.OnStepCompleted -= OnStep;
|
||||
e.OnRunCompleted -= OnRunCompleted;
|
||||
e.Activity.RunStarted -= OnRunStarted;
|
||||
e.Activity.RunFinished -= OnRunFinished;
|
||||
e.Activity.LinkObserved -= OnLink;
|
||||
}
|
||||
|
||||
host.InstanceChanged -= OnInstanceChanged;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Activity;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #711: The rules that decide what the team overview shows for an agent. Every state comes
|
||||
/// from a recorded fact, never from a model's judgement.
|
||||
/// </summary>
|
||||
public sealed class TeamOverviewTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static ActiveRun RunningFor(TimeSpan duration)
|
||||
=> new("r", "worker", "task", null, null, Now - duration);
|
||||
|
||||
[Fact]
|
||||
public void Nothing_running_and_a_good_last_run_is_idle()
|
||||
=> TeamOverview.Classify([], 0, "Completed", TimeSpan.FromMinutes(1), Now).ShouldBe(TeamState.Idle);
|
||||
|
||||
[Fact]
|
||||
public void An_agent_without_any_run_yet_is_idle()
|
||||
=> TeamOverview.Classify([], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
|
||||
|
||||
[Fact]
|
||||
public void A_run_in_progress_is_working()
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, null, TimeSpan.FromMinutes(1), Now)
|
||||
.ShouldBe(TeamState.Working);
|
||||
|
||||
[Fact]
|
||||
public void A_run_far_beyond_the_usual_duration_is_stuck()
|
||||
// Usually 2 minutes: three times that is 6, running for 7.
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(7))], 0, null, TimeSpan.FromMinutes(2), Now)
|
||||
.ShouldBe(TeamState.Stuck);
|
||||
|
||||
[Fact]
|
||||
public void Short_runs_get_a_floor_before_they_count_as_stuck()
|
||||
// Usually 20 seconds: three times is one minute, but the floor is five.
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(3))], 0, null, TimeSpan.FromSeconds(20), Now)
|
||||
.ShouldBe(TeamState.Working);
|
||||
|
||||
[Fact]
|
||||
public void Without_history_a_run_is_stuck_only_after_a_long_while()
|
||||
{
|
||||
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(10))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Working);
|
||||
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(16))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Stuck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_open_approval_outranks_working()
|
||||
// What the human can do something about comes first.
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 2, null, TimeSpan.FromMinutes(1), Now)
|
||||
.ShouldBe(TeamState.WaitingForApproval);
|
||||
|
||||
[Fact]
|
||||
public void Stuck_outranks_an_open_approval()
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromHours(1))], 2, null, TimeSpan.FromMinutes(1), Now)
|
||||
.ShouldBe(TeamState.Stuck);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Failed")]
|
||||
[InlineData("LoopLimitExceeded")]
|
||||
[InlineData("TargetBusy")]
|
||||
public void A_failed_last_run_is_a_problem(string status)
|
||||
=> TeamOverview.Classify([], 0, status, TimeSpan.Zero, Now).ShouldBe(TeamState.Problem);
|
||||
|
||||
[Fact]
|
||||
public void A_refused_last_run_shows_the_exhausted_budget()
|
||||
=> TeamOverview.Classify([], 0, "BudgetExceeded", TimeSpan.Zero, Now).ShouldBe(TeamState.BudgetExhausted);
|
||||
|
||||
[Fact]
|
||||
public void A_new_run_hides_the_old_problem()
|
||||
// A new run is the attempt to get past it; the problem shows again if it fails.
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, "Failed", TimeSpan.FromMinutes(1), Now)
|
||||
.ShouldBe(TeamState.Working);
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using ClawdDotNet.Core.Activity;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Activity;
|
||||
|
||||
/// <summary>
|
||||
/// #711: Who is working right now and who reached whom — the live side of the team overview.
|
||||
/// </summary>
|
||||
public sealed class AgentActivityTests
|
||||
{
|
||||
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
[Fact]
|
||||
public void A_run_is_active_until_its_handle_is_disposed()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
|
||||
using (var run = activity.Begin("r1", "worker", "task", "t-1", null))
|
||||
{
|
||||
var active = activity.ActiveRuns().ShouldHaveSingleItem();
|
||||
active.AgentId.ShouldBe("worker");
|
||||
active.TaskId.ShouldBe("t-1");
|
||||
active.CalledBy.ShouldBeNull();
|
||||
run.Complete("Completed");
|
||||
}
|
||||
|
||||
activity.ActiveRuns().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_and_end_are_announced_with_the_status()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
var log = new List<string>();
|
||||
activity.RunStarted += r => log.Add($"start {r.AgentId}");
|
||||
activity.RunFinished += (r, status) => log.Add($"end {r.AgentId} {status}");
|
||||
|
||||
using (var run = activity.Begin("r1", "worker", "chat", null, null))
|
||||
run.Complete("LoopLimitExceeded");
|
||||
using (activity.Begin("r2", "worker", "chat", null, null)) { }
|
||||
|
||||
log.ShouldBe(["start worker", "end worker LoopLimitExceeded", "start worker", "end worker Aborted"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_delegated_run_records_who_called_whom()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
AgentLink? seen = null;
|
||||
activity.LinkObserved += l => seen = l;
|
||||
var chain = DelegationChain.Root("lead").Append("buyer");
|
||||
|
||||
using (activity.Begin("r1", "buyer", "agent_comm", null, chain)) { }
|
||||
|
||||
seen.ShouldNotBeNull();
|
||||
seen.From.ShouldBe("lead");
|
||||
seen.To.ShouldBe("buyer");
|
||||
seen.Kind.ShouldBe("delegation");
|
||||
activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().From.ShouldBe("lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Old_links_fall_out_of_the_recent_window()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
using (activity.Begin("r1", "buyer", "agent_comm", null, DelegationChain.Root("lead").Append("buyer"))) { }
|
||||
|
||||
_clock.Advance(TimeSpan.FromMinutes(11));
|
||||
|
||||
activity.RecentLinks(TimeSpan.FromMinutes(10)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_memory_of_links_is_bounded()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
var chain = DelegationChain.Root("lead").Append("buyer");
|
||||
|
||||
for (var i = 0; i < AgentActivity.RecentLinkCapacity + 50; i++)
|
||||
using (activity.Begin($"r{i}", "buyer", "agent_comm", null, chain)) { }
|
||||
|
||||
activity.RecentLinks(TimeSpan.FromDays(1)).Count.ShouldBe(AgentActivity.RecentLinkCapacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_subscriber_that_throws_does_not_break_the_run()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
activity.RunStarted += _ => throw new InvalidOperationException("broken page");
|
||||
activity.RunFinished += (_, _) => throw new InvalidOperationException("broken page");
|
||||
|
||||
Should.NotThrow(() =>
|
||||
{
|
||||
using var run = activity.Begin("r1", "worker", "chat", null, null);
|
||||
run.Complete("Completed");
|
||||
});
|
||||
activity.ActiveRuns().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ─── through the engine ───
|
||||
|
||||
[Fact]
|
||||
public async Task The_engine_announces_a_delegated_run_with_its_caller()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
fixture.AddAgent("lead");
|
||||
fixture.AddAgent("buyer");
|
||||
fixture.Client.RespondsWithText("Erledigt.");
|
||||
|
||||
var started = new List<ActiveRun>();
|
||||
var finished = new List<string>();
|
||||
fixture.Engine.Activity.RunStarted += started.Add;
|
||||
fixture.Engine.Activity.RunFinished += (_, status) => finished.Add(status);
|
||||
|
||||
var result = await fixture.Engine.SendMessageAsync("lead", "buyer", "Bitte pruefen", "inst", null, default);
|
||||
|
||||
result.Delivered.ShouldBeTrue(result.Error);
|
||||
var run = started.ShouldHaveSingleItem();
|
||||
run.AgentId.ShouldBe("buyer");
|
||||
run.CalledBy.ShouldBe("lead");
|
||||
finished.ShouldBe(["Completed"]);
|
||||
fixture.Engine.Activity.ActiveRuns().ShouldBeEmpty();
|
||||
fixture.Engine.Activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().To.ShouldBe("buyer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_run_is_visible_too()
|
||||
{
|
||||
// Before #711 only runs through ChatAsync counted as "running"; RunAsync (@new) did not.
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("worker");
|
||||
fixture.Client.RespondsWithText("Fertig.");
|
||||
|
||||
var started = new List<ActiveRun>();
|
||||
fixture.Engine.Activity.RunStarted += started.Add;
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
started.ShouldHaveSingleItem().AgentId.ShouldBe("worker");
|
||||
}
|
||||
}
|
||||
@@ -276,17 +276,16 @@ public class HarnessHaertungTests
|
||||
("ParallelTool", "{}", "c3"))
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
sw.Stop();
|
||||
|
||||
// Der belastbare Nachweis: Aufrufe haben sich überlappt.
|
||||
//
|
||||
// Hier stand zusätzlich eine Wanduhr-Prüfung (unter 750 ms, seriell wären es 900).
|
||||
// Sie schlug am 2026-09-24 mit 927 ms fehl, als die Testprojekte parallel liefen —
|
||||
// die Zeit misst die Last der Maschine, nicht das Verhalten der Engine. Der
|
||||
// Höchststand gleichzeitiger Aufrufe beweist dasselbe, ohne zu wackeln.
|
||||
Assert.True(tool.MaxConcurrent > 1,
|
||||
$"Erwartet: überlappende Aufrufe. Höchststand war {tool.MaxConcurrent}.");
|
||||
|
||||
// Die Laufzeit als zweite, schwächere Bestätigung — seriell wären es 900 ms.
|
||||
Assert.True(sw.ElapsedMilliseconds < 750,
|
||||
$"Erwartet: deutlich unter 900 ms. Gemessen: {sw.ElapsedMilliseconds} ms.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user