Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f627918729 | ||
|
|
d587dc3f20 | ||
|
|
991a901cab | ||
|
|
0f49e04261 | ||
|
|
fe0eb09d7c |
@@ -24,3 +24,6 @@
|
|||||||
*.jpg binary
|
*.jpg binary
|
||||||
*.zip binary
|
*.zip binary
|
||||||
*.dll 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,
|
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
|
||||||
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
|
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 TelegramClientManager? TelegramClient { get; private init; }
|
||||||
public OpenRouterStatusService? Status { 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>
|
/// </summary>
|
||||||
public BudgetGuard? BudgetGuard => _budgetGuard;
|
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>
|
/// <summary>
|
||||||
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
|
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
|
||||||
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
|
/// 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 runId = Guid.NewGuid().ToString("N");
|
||||||
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
|
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 recorder = CreateRecorder(agentConfig);
|
||||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
|
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
|
||||||
|
activity.Complete(result.Status.ToString());
|
||||||
await RecordUsageAsync(agentConfig, result);
|
await RecordUsageAsync(agentConfig, result);
|
||||||
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||||
await SaveTranscriptAsync(recorder, 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
|
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 recorder = CreateRecorder(agentConfig);
|
||||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
|
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
|
||||||
|
activity.Complete(result.Status.ToString());
|
||||||
await RecordUsageAsync(agentConfig, result);
|
await RecordUsageAsync(agentConfig, result);
|
||||||
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
||||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||||
|
|||||||
@@ -1721,3 +1721,77 @@ div.klein,
|
|||||||
td.klein {
|
td.klein {
|
||||||
font-size: 12px;
|
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 { renderUsageTimeline } from "./views/usage-timeline.js";
|
||||||
import { renderRunDetail } from "./views/run-detail.js";
|
import { renderRunDetail } from "./views/run-detail.js";
|
||||||
import { renderPromptHistory } from "./views/prompt-history.js";
|
import { renderPromptHistory } from "./views/prompt-history.js";
|
||||||
|
import { renderTeam, leaveTeam } from "./views/team.js";
|
||||||
|
|
||||||
// ─── 1. Routen registrieren ───
|
// ─── 1. Routen registrieren ───
|
||||||
|
|
||||||
function verlasseAnsicht() {
|
function verlasseAnsicht() {
|
||||||
|
leaveTeam();
|
||||||
trenneLogStream();
|
trenneLogStream();
|
||||||
trenneAgentenChatsStream();
|
trenneAgentenChatsStream();
|
||||||
stoppeInstanzenPolling();
|
stoppeInstanzenPolling();
|
||||||
@@ -50,6 +52,7 @@ registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereCha
|
|||||||
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
|
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
|
||||||
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
|
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
|
||||||
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
|
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
|
||||||
|
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
|
||||||
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
|
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
|
||||||
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
|
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
|
||||||
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
|
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
|
||||||
|
|||||||
@@ -67,6 +67,28 @@
|
|||||||
"nav.chat": "Chat",
|
"nav.chat": "Chat",
|
||||||
"nav.agents": "Agenten",
|
"nav.agents": "Agenten",
|
||||||
"nav.tasks": "Aufgaben",
|
"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.services": "Dienste",
|
||||||
"nav.group.analytics": "Analyse",
|
"nav.group.analytics": "Analyse",
|
||||||
"nav.usage": "Tokennutzung",
|
"nav.usage": "Tokennutzung",
|
||||||
|
|||||||
@@ -67,6 +67,28 @@
|
|||||||
"nav.chat": "Chat",
|
"nav.chat": "Chat",
|
||||||
"nav.agents": "Agents",
|
"nav.agents": "Agents",
|
||||||
"nav.tasks": "Tasks",
|
"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.services": "Services",
|
||||||
"nav.group.analytics": "Analytics",
|
"nav.group.analytics": "Analytics",
|
||||||
"nav.usage": "Token Usage",
|
"nav.usage": "Token Usage",
|
||||||
|
|||||||
@@ -55,6 +55,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label" data-i18n="nav.agents">Agents</span>
|
<span class="nav-label" data-i18n="nav.agents">Agents</span>
|
||||||
</a>
|
</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">
|
<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">
|
<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"/>
|
<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);
|
meldeStatus(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
|
const bekannteEreignisse = ["chat", "step", "run", "instance", "status", "activity"];
|
||||||
for (const art of bekannteEreignisse) {
|
for (const art of bekannteEreignisse) {
|
||||||
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
|
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>
|
/// </summary>
|
||||||
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
|
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>
|
/// <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);
|
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)
|
public static ChatEntryDto ToDto(this ChatEntry entry)
|
||||||
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
|
=> 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)
|
public static TaskDto ToDto(this TaskOverview overview)
|
||||||
=> overview.Task.ToDto() with
|
=> overview.Task.ToDto() with
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ internal static class ApiEndpoints
|
|||||||
api.MapUsage();
|
api.MapUsage();
|
||||||
api.MapAnalytics();
|
api.MapAnalytics();
|
||||||
api.MapLogs();
|
api.MapLogs();
|
||||||
|
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
|
||||||
|
|
||||||
app.MapEvents();
|
app.MapEvents();
|
||||||
app.MapControl();
|
app.MapControl();
|
||||||
|
|||||||
@@ -71,12 +71,23 @@ internal static class EventEndpoints
|
|||||||
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
|
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
|
||||||
=> Publish("run", new
|
=> Publish("run", new
|
||||||
{
|
{
|
||||||
|
agentId = result.AgentId,
|
||||||
model,
|
model,
|
||||||
status = result.Status.ToString(),
|
status = result.Status.ToString(),
|
||||||
steps = result.StepCount,
|
steps = result.StepCount,
|
||||||
tokens = result.TokensUsed
|
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)
|
void OnInstanceChanged(InstanceChange change)
|
||||||
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
|
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
|
||||||
|
|
||||||
@@ -88,6 +99,9 @@ internal static class EventEndpoints
|
|||||||
engine.OnChatEntryAdded += OnChat;
|
engine.OnChatEntryAdded += OnChat;
|
||||||
engine.OnStepCompleted += OnStep;
|
engine.OnStepCompleted += OnStep;
|
||||||
engine.OnRunCompleted += OnRunCompleted;
|
engine.OnRunCompleted += OnRunCompleted;
|
||||||
|
engine.Activity.RunStarted += OnRunStarted;
|
||||||
|
engine.Activity.RunFinished += OnRunFinished;
|
||||||
|
engine.Activity.LinkObserved += OnLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
host.InstanceChanged += OnInstanceChanged;
|
host.InstanceChanged += OnInstanceChanged;
|
||||||
@@ -106,6 +120,9 @@ internal static class EventEndpoints
|
|||||||
e.OnChatEntryAdded -= OnChat;
|
e.OnChatEntryAdded -= OnChat;
|
||||||
e.OnStepCompleted -= OnStep;
|
e.OnStepCompleted -= OnStep;
|
||||||
e.OnRunCompleted -= OnRunCompleted;
|
e.OnRunCompleted -= OnRunCompleted;
|
||||||
|
e.Activity.RunStarted -= OnRunStarted;
|
||||||
|
e.Activity.RunFinished -= OnRunFinished;
|
||||||
|
e.Activity.LinkObserved -= OnLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
host.InstanceChanged -= OnInstanceChanged;
|
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"))
|
("ParallelTool", "{}", "c3"))
|
||||||
.RespondsWithText("Fertig.");
|
.RespondsWithText("Fertig.");
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
|
||||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||||
sw.Stop();
|
|
||||||
|
|
||||||
// Der belastbare Nachweis: Aufrufe haben sich überlappt.
|
// 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,
|
Assert.True(tool.MaxConcurrent > 1,
|
||||||
$"Erwartet: überlappende Aufrufe. Höchststand war {tool.MaxConcurrent}.");
|
$"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]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user