Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ecf2d5ca | ||
|
|
68a432fec6 | ||
|
|
626fc8d593 | ||
|
|
d014a20c38 | ||
|
|
dc11961805 | ||
|
|
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; }
|
||||||
|
|
||||||
@@ -211,6 +214,9 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
|
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
|
||||||
public AgentAdministration AgentAdmin => _agentAdmin.Value;
|
public AgentAdministration AgentAdmin => _agentAdmin.Value;
|
||||||
|
|
||||||
|
/// <summary>#683: Units, moves, tool ceilings, unit-wide values. Stateless.</summary>
|
||||||
|
public OrgAdministration Organisation => new(this, LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation"));
|
||||||
|
|
||||||
/// <summary>Dienste der Instanz anlegen, entfernen, umschalten.</summary>
|
/// <summary>Dienste der Instanz anlegen, entfernen, umschalten.</summary>
|
||||||
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
|
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
|
||||||
|
|
||||||
@@ -562,6 +568,12 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
|
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #683: The organisation tree, fresh from the configuration on every question, so a
|
||||||
|
// change made through the administration applies to the next run without a restart.
|
||||||
|
engine.OrganisationProvider = () => Core.Organisation.OrgChart.From(instance);
|
||||||
|
foreach (var problem in Core.Organisation.OrgChart.From(instance).Problems())
|
||||||
|
logger.LogWarning("Organisation: {Problem}", problem);
|
||||||
|
|
||||||
engine.LoadPersistedChats();
|
engine.LoadPersistedChats();
|
||||||
|
|
||||||
var (scanner, staging, board) = BuildTaskboard(
|
var (scanner, staging, board) = BuildTaskboard(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ClawdDotNet.Core.Config;
|
using ClawdDotNet.Core.Config;
|
||||||
using ClawdDotNet.Core.Instances;
|
using ClawdDotNet.Core.Instances;
|
||||||
using ClawdDotNet.Core.Staging;
|
using ClawdDotNet.Core.Staging;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ClawdDotNet.App.Services;
|
namespace ClawdDotNet.App.Services;
|
||||||
|
|
||||||
@@ -9,7 +10,8 @@ public sealed record NewAgent(
|
|||||||
string AgentId,
|
string AgentId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string Model,
|
string Model,
|
||||||
string Description = "");
|
string Description = "",
|
||||||
|
string? UnitId = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
|
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
|
||||||
@@ -86,6 +88,10 @@ public sealed class AgentAdministration(AppHost host)
|
|||||||
if (Find(agentId) is not null)
|
if (Find(agentId) is not null)
|
||||||
return AdminResult.Failure($"Ein Agent mit der ID '{agentId}' existiert bereits.");
|
return AdminResult.Failure($"Ein Agent mit der ID '{agentId}' existiert bereits.");
|
||||||
|
|
||||||
|
var unitId = string.IsNullOrWhiteSpace(request.UnitId) ? null : request.UnitId.Trim();
|
||||||
|
if (unitId is not null && host.Organisation.Current().Unit(unitId) is null)
|
||||||
|
return AdminResult.Failure($"There is no unit '{unitId}'.");
|
||||||
|
|
||||||
string agentDir;
|
string agentDir;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -111,6 +117,14 @@ public sealed class AgentAdministration(AppHost host)
|
|||||||
config.Model = request.Model;
|
config.Model = request.Model;
|
||||||
config.Description = request.Description;
|
config.Description = request.Description;
|
||||||
|
|
||||||
|
// #683: Placed into a unit, the agent starts with the unit's defaults.
|
||||||
|
if (unitId is not null)
|
||||||
|
{
|
||||||
|
config.UnitId = unitId;
|
||||||
|
config.UnitSinceUtc = DateTime.UtcNow;
|
||||||
|
host.Organisation.ApplyDefaultsToNewAgent(config);
|
||||||
|
}
|
||||||
|
|
||||||
host.Directories.SaveAgentSettings(agentDir, config);
|
host.Directories.SaveAgentSettings(agentDir, config);
|
||||||
|
|
||||||
host.Instance.Agents.Add(config);
|
host.Instance.Agents.Add(config);
|
||||||
@@ -181,6 +195,10 @@ public sealed class AgentAdministration(AppHost host)
|
|||||||
|
|
||||||
if (enabled)
|
if (enabled)
|
||||||
{
|
{
|
||||||
|
// #683: What the unit may not have, a member cannot be granted.
|
||||||
|
if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
|
||||||
|
return AdminResult.Failure(refused);
|
||||||
|
|
||||||
if (!agent.Tools.ContainsKey(toolName))
|
if (!agent.Tools.ContainsKey(toolName))
|
||||||
agent.Tools[toolName] = new Dictionary<string, object?>();
|
agent.Tools[toolName] = new Dictionary<string, object?>();
|
||||||
}
|
}
|
||||||
@@ -278,6 +296,10 @@ public sealed class AgentAdministration(AppHost host)
|
|||||||
if (Find(agentId) is not { } agent)
|
if (Find(agentId) is not { } agent)
|
||||||
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
||||||
|
|
||||||
|
// #683: Settings for a tool the agent does not have yet grant it — the ceiling applies.
|
||||||
|
if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
|
||||||
|
return AdminResult.Failure(refused);
|
||||||
|
|
||||||
// #641: Die Einstellungsmasken kennen die Rechte nicht und schreiben ein frisches
|
// #641: Die Einstellungsmasken kennen die Rechte nicht und schreiben ein frisches
|
||||||
// Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und
|
// Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und
|
||||||
// "verboten" - und damit die Rechte des Agenten.
|
// "verboten" - und damit die Rechte des Agenten.
|
||||||
@@ -309,6 +331,22 @@ public sealed class AgentAdministration(AppHost host)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void Save()
|
public void Save()
|
||||||
{
|
{
|
||||||
|
// #683: The desktop toggles tools directly on the configuration objects and only
|
||||||
|
// saves here. The ceiling is a rule of writing, so it is enforced here as well: a
|
||||||
|
// tool beyond it does not get saved.
|
||||||
|
var chart = host.Organisation.Current();
|
||||||
|
foreach (var agent in host.Instance.Agents)
|
||||||
|
{
|
||||||
|
if (chart.ToolCeiling(agent.UnitId) is not { } ceiling)
|
||||||
|
continue;
|
||||||
|
foreach (var tool in agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList())
|
||||||
|
{
|
||||||
|
agent.Tools.Remove(tool);
|
||||||
|
host.LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation").LogWarning(
|
||||||
|
"Tool {Tool} removed from {AgentId}: beyond the tool ceiling of its unit", tool, agent.AgentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var agent in host.Instance.Agents)
|
foreach (var agent in host.Instance.Agents)
|
||||||
PersistAgent(agent);
|
PersistAgent(agent);
|
||||||
|
|
||||||
@@ -329,7 +367,7 @@ public sealed class AgentAdministration(AppHost host)
|
|||||||
/// werden sie gelesen, und was in <c>AgentSettings.json</c> danebenstünde, überschriebe
|
/// werden sie gelesen, und was in <c>AgentSettings.json</c> danebenstünde, überschriebe
|
||||||
/// der nächste Ladevorgang ohnehin.</para>
|
/// der nächste Ladevorgang ohnehin.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void PersistAgent(AgentConfig agent)
|
internal void PersistAgent(AgentConfig agent)
|
||||||
{
|
{
|
||||||
// Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten
|
// Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten
|
||||||
// Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des
|
// Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClawdDotNet.Core.Audit;
|
||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Organisation;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.App.Services;
|
||||||
|
|
||||||
|
/// <summary>A unit-wide value that can be written into the members (#683).</summary>
|
||||||
|
public enum UnitSetting
|
||||||
|
{
|
||||||
|
DailyCostUsd,
|
||||||
|
DailyTokens,
|
||||||
|
Model,
|
||||||
|
WhenExhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How a unit-wide number is turned into per-agent values.</summary>
|
||||||
|
public enum UnitSettingMode
|
||||||
|
{
|
||||||
|
/// <summary>Every agent gets the value ("each gets 10 $").</summary>
|
||||||
|
PerAgent,
|
||||||
|
|
||||||
|
/// <summary>The value is a total, split in proportion to the current values ("the unit gets 100 $").</summary>
|
||||||
|
Distribute
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One line of a preview: what would change at one agent.</summary>
|
||||||
|
public sealed record UnitSettingChange(
|
||||||
|
string AgentId,
|
||||||
|
string DisplayName,
|
||||||
|
string Current,
|
||||||
|
string Proposed,
|
||||||
|
bool Outlier,
|
||||||
|
bool SelectedByDefault);
|
||||||
|
|
||||||
|
/// <summary>What a move or a lowered ceiling would do before it is done.</summary>
|
||||||
|
public sealed record StructurePreview(
|
||||||
|
IReadOnlyList<string> AffectedAgents,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<string>> ToolsLost,
|
||||||
|
IReadOnlyList<string> Running,
|
||||||
|
IReadOnlyList<string> Problems)
|
||||||
|
{
|
||||||
|
public bool CanApply => Running.Count == 0 && Problems.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: Units — create, rename, lead, move, limit — and unit-wide values written into the
|
||||||
|
/// members. The rules live here, not in a user interface, so the WebUI and the desktop
|
||||||
|
/// apply the same ones (like <see cref="AgentAdministration"/>).
|
||||||
|
///
|
||||||
|
/// <para><b>Three rules from the concept (section 3):</b></para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><b>No value overrides another at run time.</b> A unit-wide value is written into
|
||||||
|
/// the agents after a preview; the unit keeps it only as the default for agents that join.
|
||||||
|
/// Outliers — the specialist with a deliberately large budget — are marked and not
|
||||||
|
/// selected by default.</item>
|
||||||
|
/// <item><b>The tool ceiling is checked when writing.</b> A tool beyond it cannot be
|
||||||
|
/// granted; lowering it removes the tools from the agents, after a preview.</item>
|
||||||
|
/// <item><b>A move never happens under a running agent.</b> The concept says "with the
|
||||||
|
/// instance stopped", so that no agent gets different rights in the middle of a run. The
|
||||||
|
/// administration runs inside the instance and cannot stop it; it refuses while an affected
|
||||||
|
/// agent runs, which serves the same purpose more narrowly.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Every structural change is written to the audit log with its reason — a change of
|
||||||
|
/// structure is a change of rights (#205, #641).</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrgAdministration(AppHost host, ILogger logger)
|
||||||
|
{
|
||||||
|
/// <summary>A value this far from the median of the others is an outlier.</summary>
|
||||||
|
public const decimal OutlierFactor = 3m;
|
||||||
|
|
||||||
|
private OrgChart Chart => OrgChart.From(host.Instance);
|
||||||
|
|
||||||
|
public OrgChart Current() => Chart;
|
||||||
|
|
||||||
|
// ─── units ───
|
||||||
|
|
||||||
|
public AdminResult CreateUnit(string id, string name, string? parentId, string? goal = null, string reason = "")
|
||||||
|
{
|
||||||
|
id = (id ?? "").Trim();
|
||||||
|
if (id.Length == 0 || id.Any(c => !(char.IsLetterOrDigit(c) || c is '-' or '_')))
|
||||||
|
return AdminResult.Failure("A unit id consists of letters, digits, '-' and '_'.");
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
return AdminResult.Failure("A unit needs a name.");
|
||||||
|
if (Chart.Unit(id) is not null)
|
||||||
|
return AdminResult.Failure($"There is already a unit '{id}'.");
|
||||||
|
if (!string.IsNullOrWhiteSpace(parentId) && Chart.Unit(parentId) is null)
|
||||||
|
return AdminResult.Failure($"There is no unit '{parentId}' to put it under.");
|
||||||
|
|
||||||
|
host.Instance.Units.Add(new UnitConfig
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name.Trim(),
|
||||||
|
ParentId = string.IsNullOrWhiteSpace(parentId) ? null : parentId.Trim(),
|
||||||
|
Goal = goal?.Trim() ?? ""
|
||||||
|
});
|
||||||
|
|
||||||
|
Save(null, "unit_create", new { id, name, parentId, goal }, reason);
|
||||||
|
return AdminResult.Success($"Unit '{name.Trim()}' created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdminResult UpdateUnit(string unitId, string? name, string? goal, string reason = "")
|
||||||
|
{
|
||||||
|
if (FindUnit(unitId) is not { } unit)
|
||||||
|
return AdminResult.Failure($"There is no unit '{unitId}'.");
|
||||||
|
|
||||||
|
if (name is not null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
return AdminResult.Failure("A unit needs a name.");
|
||||||
|
unit.Name = name.Trim();
|
||||||
|
}
|
||||||
|
if (goal is not null)
|
||||||
|
unit.Goal = goal.Trim();
|
||||||
|
|
||||||
|
Save(null, "unit_update", new { unitId, name, goal }, reason);
|
||||||
|
return AdminResult.Success($"Unit '{unit.Name}' updated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Only an empty unit can go — members and sub-units have to be moved first.</summary>
|
||||||
|
public AdminResult DeleteUnit(string unitId, string reason = "")
|
||||||
|
{
|
||||||
|
if (FindUnit(unitId) is not { } unit)
|
||||||
|
return AdminResult.Failure($"There is no unit '{unitId}'.");
|
||||||
|
var chart = Chart;
|
||||||
|
if (chart.MembersOf(unit.Id).Count > 0 || chart.Children(unit.Id).Count > 0)
|
||||||
|
return AdminResult.Failure($"Unit '{unit.Name}' still has members or sub-units. Move them first.");
|
||||||
|
|
||||||
|
host.Instance.Units.Remove(unit);
|
||||||
|
Save(null, "unit_delete", new { unitId }, reason);
|
||||||
|
return AdminResult.Success($"Unit '{unit.Name}' deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The lead must be a member of the unit. <c>null</c> leaves it without a lead.</summary>
|
||||||
|
public AdminResult SetLeader(string unitId, string? agentId, string reason = "")
|
||||||
|
{
|
||||||
|
if (FindUnit(unitId) is not { } unit)
|
||||||
|
return AdminResult.Failure($"There is no unit '{unitId}'.");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(agentId))
|
||||||
|
{
|
||||||
|
if (host.AgentAdmin.Find(agentId) is not { } agent)
|
||||||
|
return AdminResult.Failure($"There is no agent '{agentId}'.");
|
||||||
|
if (!string.Equals(agent.UnitId, unit.Id, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return AdminResult.Failure($"'{agentId}' is not a member of '{unit.Name}'. Move it there first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
unit.LeaderAgentId = string.IsNullOrWhiteSpace(agentId) ? null : agentId.Trim();
|
||||||
|
Save(null, "unit_lead", new { unitId, agentId }, reason);
|
||||||
|
return AdminResult.Success(unit.LeaderAgentId is null
|
||||||
|
? $"'{unit.Name}' has no lead now; the lead above takes over."
|
||||||
|
: $"'{unit.LeaderAgentId}' leads '{unit.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── moves ───
|
||||||
|
|
||||||
|
public StructurePreview PreviewMoveAgent(string agentId, string? targetUnitId)
|
||||||
|
{
|
||||||
|
var chart = Chart;
|
||||||
|
var problems = new List<string>();
|
||||||
|
if (chart.Agent(agentId) is null)
|
||||||
|
problems.Add($"There is no agent '{agentId}'.");
|
||||||
|
if (!string.IsNullOrWhiteSpace(targetUnitId) && chart.Unit(targetUnitId) is null)
|
||||||
|
problems.Add($"There is no unit '{targetUnitId}'.");
|
||||||
|
|
||||||
|
return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves an agent. Tools beyond the new unit's ceiling are removed only with
|
||||||
|
/// <paramref name="dropExcessTools"/> — otherwise the move is refused and says which.
|
||||||
|
/// A lead leaving its unit leaves it without a lead.
|
||||||
|
/// </summary>
|
||||||
|
public AdminResult MoveAgent(string agentId, string? targetUnitId, string reason, bool dropExcessTools = false)
|
||||||
|
{
|
||||||
|
targetUnitId = NullIfEmpty(targetUnitId);
|
||||||
|
var preview = PreviewMoveAgent(agentId, targetUnitId);
|
||||||
|
if (Refusal(preview, dropExcessTools) is { } refused)
|
||||||
|
return refused;
|
||||||
|
|
||||||
|
var agent = host.AgentAdmin.Find(agentId)!;
|
||||||
|
if (string.Equals(agent.UnitId, targetUnitId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return AdminResult.Success($"'{agentId}' is already there.");
|
||||||
|
|
||||||
|
var from = agent.UnitId;
|
||||||
|
if (FindUnit(from) is { } oldUnit
|
||||||
|
&& string.Equals(oldUnit.LeaderAgentId, agentId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
oldUnit.LeaderAgentId = null;
|
||||||
|
|
||||||
|
DropTools(preview);
|
||||||
|
agent.PreviousUnitId = from;
|
||||||
|
agent.UnitId = targetUnitId;
|
||||||
|
agent.UnitSinceUtc = DateTime.UtcNow;
|
||||||
|
host.AgentAdmin.PersistAgent(agent);
|
||||||
|
|
||||||
|
Save(agentId, "agent_move", new { agentId, from, to = targetUnitId, toolsLost = preview.ToolsLost }, reason);
|
||||||
|
return AdminResult.Success($"'{agentId}' moved to {Describe(targetUnitId)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public StructurePreview PreviewMoveUnit(string unitId, string? newParentId)
|
||||||
|
{
|
||||||
|
var chart = Chart;
|
||||||
|
var problems = new List<string>();
|
||||||
|
newParentId = NullIfEmpty(newParentId);
|
||||||
|
|
||||||
|
if (chart.Unit(unitId) is null)
|
||||||
|
problems.Add($"There is no unit '{unitId}'.");
|
||||||
|
else if (newParentId is not null)
|
||||||
|
{
|
||||||
|
if (chart.Unit(newParentId) is null)
|
||||||
|
problems.Add($"There is no unit '{newParentId}'.");
|
||||||
|
else if (string.Equals(newParentId, unitId, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| chart.Ancestors(newParentId).Any(u => string.Equals(u.Id, unitId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
problems.Add("A unit cannot be moved under itself or one of its own sub-units.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).Select(a => a.AgentId).ToList();
|
||||||
|
return Preview(agents, targetUnitId: null, movingUnit: (unitId, newParentId), problems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves a unit with everything in it. Its members keep their unit; what changes is
|
||||||
|
/// what lies above — leads, reach and possibly the tool ceiling.
|
||||||
|
/// </summary>
|
||||||
|
public AdminResult MoveUnit(string unitId, string? newParentId, string reason, bool dropExcessTools = false)
|
||||||
|
{
|
||||||
|
newParentId = NullIfEmpty(newParentId);
|
||||||
|
var preview = PreviewMoveUnit(unitId, newParentId);
|
||||||
|
if (Refusal(preview, dropExcessTools) is { } refused)
|
||||||
|
return refused;
|
||||||
|
|
||||||
|
var unit = FindUnit(unitId)!;
|
||||||
|
var from = unit.ParentId;
|
||||||
|
DropTools(preview);
|
||||||
|
unit.ParentId = newParentId;
|
||||||
|
|
||||||
|
// The members changed position although not unit: tell them, like a move.
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var agentId in preview.AffectedAgents)
|
||||||
|
{
|
||||||
|
if (host.AgentAdmin.Find(agentId) is { } agent)
|
||||||
|
{
|
||||||
|
agent.UnitSinceUtc = now;
|
||||||
|
host.AgentAdmin.PersistAgent(agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Save(null, "unit_move", new { unitId, from, to = newParentId, toolsLost = preview.ToolsLost }, reason);
|
||||||
|
return AdminResult.Success($"Unit '{unit.Name}' moved under {Describe(newParentId)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tool ceiling ───
|
||||||
|
|
||||||
|
public StructurePreview PreviewToolCeiling(string unitId, IReadOnlyCollection<string>? tools)
|
||||||
|
{
|
||||||
|
var chart = Chart;
|
||||||
|
var problems = new List<string>();
|
||||||
|
if (chart.Unit(unitId) is null)
|
||||||
|
problems.Add($"There is no unit '{unitId}'.");
|
||||||
|
|
||||||
|
var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).Select(a => a.AgentId).ToList();
|
||||||
|
return Preview(agents, targetUnitId: null, movingUnit: null, problems, ceiling: (unitId, tools));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the ceiling. Lowering it removes the tools beyond it from the members — shown by
|
||||||
|
/// <see cref="PreviewToolCeiling"/> first, and only with <paramref name="dropExcessTools"/>,
|
||||||
|
/// like a move. <c>null</c> lifts the ceiling.
|
||||||
|
/// </summary>
|
||||||
|
public AdminResult SetToolCeiling(
|
||||||
|
string unitId, IReadOnlyCollection<string>? tools, string reason, bool dropExcessTools = false)
|
||||||
|
{
|
||||||
|
var preview = PreviewToolCeiling(unitId, tools);
|
||||||
|
if (Refusal(preview, dropExcessTools) is { } refused)
|
||||||
|
return refused;
|
||||||
|
|
||||||
|
var unit = FindUnit(unitId)!;
|
||||||
|
DropTools(preview);
|
||||||
|
unit.ToolCeiling = tools?.Select(t => t.Trim()).Where(t => t.Length > 0)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
|
||||||
|
Save(null, "unit_ceiling", new { unitId, tools = unit.ToolCeiling, toolsLost = preview.ToolsLost }, reason);
|
||||||
|
return AdminResult.Success(unit.ToolCeiling is null
|
||||||
|
? $"'{unit.Name}' has no tool ceiling now."
|
||||||
|
: $"Tool ceiling of '{unit.Name}' set: {string.Join(", ", unit.ToolCeiling)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether an agent may be granted a tool — called by <see cref="AgentAdministration"/>
|
||||||
|
/// when writing. <c>null</c> means yes; otherwise the reason.
|
||||||
|
/// </summary>
|
||||||
|
public string? CeilingRefusal(AgentConfig agent, string toolName)
|
||||||
|
{
|
||||||
|
if (Chart.ToolCeiling(agent.UnitId) is not { } ceiling || ceiling.Contains(toolName))
|
||||||
|
return null;
|
||||||
|
return $"'{toolName}' is beyond the tool ceiling of the unit '{Chart.UnitOf(agent.AgentId)?.Name}'. " +
|
||||||
|
"Raise the ceiling of the unit or move the agent.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── unit-wide values ───
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What writing a unit-wide value would change, per agent below the unit (sub-units
|
||||||
|
/// included). Outliers are marked and not selected by default: the specialist with a
|
||||||
|
/// deliberately large budget must not be overwritten by accident.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<UnitSettingChange> PreviewSetting(
|
||||||
|
string unitId, UnitSetting setting, string value, UnitSettingMode mode = UnitSettingMode.PerAgent)
|
||||||
|
{
|
||||||
|
var chart = Chart;
|
||||||
|
if (chart.Unit(unitId) is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).ToList();
|
||||||
|
var numeric = setting is UnitSetting.DailyCostUsd or UnitSetting.DailyTokens;
|
||||||
|
|
||||||
|
if (!numeric)
|
||||||
|
{
|
||||||
|
return agents.Select(a => new UnitSettingChange(
|
||||||
|
a.AgentId, a.DisplayName, CurrentText(a, setting), value.Trim(), Outlier: false,
|
||||||
|
SelectedByDefault: true)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decimal.TryParse(value, System.Globalization.NumberStyles.Number,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var number) || number < 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var current = agents.Select(a => CurrentNumber(a, setting)).ToList();
|
||||||
|
var outliers = OutlierFlags(current);
|
||||||
|
|
||||||
|
IReadOnlyList<decimal> proposed;
|
||||||
|
if (mode == UnitSettingMode.Distribute)
|
||||||
|
{
|
||||||
|
// In proportion to the current values, so a specialist keeps its larger share.
|
||||||
|
// Without any current values, evenly.
|
||||||
|
var sum = current.Sum();
|
||||||
|
proposed = current.Select(c => sum > 0 ? number * c / sum : agents.Count == 0 ? 0 : number / agents.Count).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
proposed = current.Select(_ => number).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return agents.Select((a, i) => new UnitSettingChange(
|
||||||
|
a.AgentId, a.DisplayName,
|
||||||
|
Format(current[i], setting), Format(proposed[i], setting),
|
||||||
|
outliers[i], SelectedByDefault: !outliers[i])).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the values chosen from a preview into the agents, one audit entry each, and —
|
||||||
|
/// with <paramref name="rememberAsDefault"/> — keeps the value as the unit's default for
|
||||||
|
/// agents that join.
|
||||||
|
/// </summary>
|
||||||
|
public AdminResult ApplySetting(
|
||||||
|
string unitId, UnitSetting setting, IReadOnlyDictionary<string, string> valuesByAgent,
|
||||||
|
string reason, string? defaultValue = null)
|
||||||
|
{
|
||||||
|
if (FindUnit(unitId) is not { } unit)
|
||||||
|
return AdminResult.Failure($"There is no unit '{unitId}'.");
|
||||||
|
|
||||||
|
var chart = Chart;
|
||||||
|
var below = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id))
|
||||||
|
.Select(a => a.AgentId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var written = 0;
|
||||||
|
foreach (var (agentId, value) in valuesByAgent)
|
||||||
|
{
|
||||||
|
if (!below.Contains(agentId) || host.AgentAdmin.Find(agentId) is not { } agent)
|
||||||
|
return AdminResult.Failure($"'{agentId}' is not below '{unit.Name}'.");
|
||||||
|
if (Write(agent, setting, value) is { } invalid)
|
||||||
|
return AdminResult.Failure(invalid);
|
||||||
|
|
||||||
|
host.AgentAdmin.PersistAgent(agent);
|
||||||
|
Audit(agentId, "unit_setting", new { unitId, setting = setting.ToString(), value }, reason);
|
||||||
|
written++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultValue is not null)
|
||||||
|
RememberDefault(unit, setting, defaultValue);
|
||||||
|
|
||||||
|
Save(null, "unit_setting_done", new { unitId, setting = setting.ToString(), written, defaultValue }, reason);
|
||||||
|
return AdminResult.Success($"{written} agent(s) in '{unit.Name}' updated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gives an agent that is new to a unit the unit's defaults — only then. A move does not
|
||||||
|
/// apply them: it would overwrite exactly the values an experienced agent was tuned to.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyDefaultsToNewAgent(AgentConfig agent)
|
||||||
|
{
|
||||||
|
if (FindUnit(agent.UnitId) is not { } unit)
|
||||||
|
return;
|
||||||
|
var d = unit.Defaults;
|
||||||
|
if (!string.IsNullOrWhiteSpace(d.Model)) agent.Model = d.Model;
|
||||||
|
if (d.DailyCostUsd is { } cost) agent.Budget.DailyCostUsd = cost;
|
||||||
|
if (d.DailyTokens is { } tokens) agent.Budget.DailyTokens = tokens;
|
||||||
|
if (!string.IsNullOrWhiteSpace(d.WhenExhausted)) agent.Budget.WhenExhausted = d.WhenExhausted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ───
|
||||||
|
|
||||||
|
private StructurePreview Preview(
|
||||||
|
IReadOnlyList<string> agents, string? targetUnitId, (string UnitId, string? NewParent)? movingUnit,
|
||||||
|
List<string> problems, (string UnitId, IReadOnlyCollection<string>? Tools)? ceiling = null)
|
||||||
|
{
|
||||||
|
// The structure as it would be afterwards — the ceiling is computed on that.
|
||||||
|
var units = host.Instance.Units.Select(Clone).ToList();
|
||||||
|
if (movingUnit is { } m && units.FirstOrDefault(u => Same(u.Id, m.UnitId)) is { } moved)
|
||||||
|
moved.ParentId = m.NewParent;
|
||||||
|
if (ceiling is { } c && units.FirstOrDefault(u => Same(u.Id, c.UnitId)) is { } limited)
|
||||||
|
limited.ToolCeiling = c.Tools?.ToList();
|
||||||
|
|
||||||
|
var after = new OrgChart(units, host.Instance.Agents);
|
||||||
|
var lost = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var agentId in agents)
|
||||||
|
{
|
||||||
|
if (host.AgentAdmin.Find(agentId) is not { } agent)
|
||||||
|
continue;
|
||||||
|
// Moving an agent: its new unit. Moving a unit or setting a ceiling: every agent
|
||||||
|
// stays in its unit — what changes is the structure above it.
|
||||||
|
var unitAfter = movingUnit is null && ceiling is null ? targetUnitId : agent.UnitId;
|
||||||
|
if (after.ToolCeiling(unitAfter) is { } limit)
|
||||||
|
{
|
||||||
|
var beyond = agent.Tools.Keys.Where(t => !limit.Contains(t)).ToList();
|
||||||
|
if (beyond.Count > 0)
|
||||||
|
lost[agentId] = beyond;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var running = (host.Engine?.Activity.ActiveRuns() ?? [])
|
||||||
|
.Select(r => r.AgentId)
|
||||||
|
.Where(id => agents.Contains(id, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new StructurePreview(agents, lost, running, problems);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminResult? Refusal(StructurePreview preview, bool dropExcessTools)
|
||||||
|
{
|
||||||
|
if (preview.Problems.Count > 0)
|
||||||
|
return AdminResult.Failure(string.Join(" ", preview.Problems));
|
||||||
|
if (preview.Running.Count > 0)
|
||||||
|
return AdminResult.Failure(
|
||||||
|
$"Not while {string.Join(", ", preview.Running)} run(s): an agent must not get different rights in the middle of a run. Try again when the run is over.");
|
||||||
|
if (preview.ToolsLost.Count > 0 && !dropExcessTools)
|
||||||
|
return AdminResult.Failure(
|
||||||
|
"The new position has a tool ceiling these agents are beyond: " +
|
||||||
|
string.Join("; ", preview.ToolsLost.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value)}")) +
|
||||||
|
". Confirm to remove those tools, or raise the ceiling first.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DropTools(StructurePreview preview)
|
||||||
|
{
|
||||||
|
foreach (var (agentId, tools) in preview.ToolsLost)
|
||||||
|
{
|
||||||
|
if (host.AgentAdmin.Find(agentId) is not { } agent)
|
||||||
|
continue;
|
||||||
|
foreach (var tool in tools)
|
||||||
|
agent.Tools.Remove(tool);
|
||||||
|
host.AgentAdmin.PersistAgent(agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<UnitConfig> Subtree(OrgChart chart, string unitId)
|
||||||
|
{
|
||||||
|
if (chart.Unit(unitId) is not { } root)
|
||||||
|
yield break;
|
||||||
|
var stack = new Stack<UnitConfig>([root]);
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
var unit = stack.Pop();
|
||||||
|
if (!seen.Add(unit.Id))
|
||||||
|
continue;
|
||||||
|
yield return unit;
|
||||||
|
foreach (var child in chart.Children(unit.Id))
|
||||||
|
stack.Push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool[] OutlierFlags(IReadOnlyList<decimal> values)
|
||||||
|
{
|
||||||
|
var flags = new bool[values.Count];
|
||||||
|
for (var i = 0; i < values.Count; i++)
|
||||||
|
{
|
||||||
|
var others = values.Where((_, j) => j != i && values[j] > 0).Order().ToList();
|
||||||
|
if (others.Count == 0 || values[i] <= 0)
|
||||||
|
continue;
|
||||||
|
var median = others[others.Count / 2];
|
||||||
|
flags[i] = values[i] >= median * OutlierFactor || values[i] <= median / OutlierFactor;
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CurrentNumber(AgentConfig agent, UnitSetting setting) => setting switch
|
||||||
|
{
|
||||||
|
UnitSetting.DailyCostUsd => agent.Budget.DailyCostUsd,
|
||||||
|
UnitSetting.DailyTokens => agent.Budget.DailyTokens,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string CurrentText(AgentConfig agent, UnitSetting setting) => setting switch
|
||||||
|
{
|
||||||
|
UnitSetting.Model => agent.Model,
|
||||||
|
UnitSetting.WhenExhausted => agent.Budget.WhenExhausted,
|
||||||
|
_ => Format(CurrentNumber(agent, setting), setting)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Format(decimal value, UnitSetting setting) => setting == UnitSetting.DailyTokens
|
||||||
|
? decimal.Round(value).ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
: decimal.Round(value, 2).ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static string? Write(AgentConfig agent, UnitSetting setting, string value)
|
||||||
|
{
|
||||||
|
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
switch (setting)
|
||||||
|
{
|
||||||
|
case UnitSetting.DailyCostUsd when decimal.TryParse(value, System.Globalization.NumberStyles.Number, inv, out var cost) && cost >= 0:
|
||||||
|
agent.Budget.DailyCostUsd = cost;
|
||||||
|
return null;
|
||||||
|
case UnitSetting.DailyTokens when long.TryParse(value, System.Globalization.NumberStyles.Number, inv, out var tokens) && tokens >= 0:
|
||||||
|
agent.Budget.DailyTokens = tokens;
|
||||||
|
return null;
|
||||||
|
case UnitSetting.Model when !string.IsNullOrWhiteSpace(value):
|
||||||
|
agent.Model = value.Trim();
|
||||||
|
return null;
|
||||||
|
case UnitSetting.WhenExhausted when value.Trim() is "expire" or "catchUpOnce":
|
||||||
|
agent.Budget.WhenExhausted = value.Trim();
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return $"'{value}' is not a valid value for {setting}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RememberDefault(UnitConfig unit, UnitSetting setting, string value)
|
||||||
|
{
|
||||||
|
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
switch (setting)
|
||||||
|
{
|
||||||
|
case UnitSetting.DailyCostUsd: unit.Defaults.DailyCostUsd = decimal.Parse(value, inv); break;
|
||||||
|
case UnitSetting.DailyTokens: unit.Defaults.DailyTokens = long.Parse(value, inv); break;
|
||||||
|
case UnitSetting.Model: unit.Defaults.Model = value.Trim(); break;
|
||||||
|
case UnitSetting.WhenExhausted: unit.Defaults.WhenExhausted = value.Trim(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UnitConfig? FindUnit(string? unitId)
|
||||||
|
=> unitId is null ? null : host.Instance.Units.FirstOrDefault(u => Same(u.Id, unitId));
|
||||||
|
|
||||||
|
private string Describe(string? unitId)
|
||||||
|
=> FindUnit(unitId) is { } unit ? $"'{unit.Name}'" : "the top level";
|
||||||
|
|
||||||
|
private static UnitConfig Clone(UnitConfig u) => new()
|
||||||
|
{
|
||||||
|
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
|
||||||
|
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private void Save(string? agentId, string action, object details, string reason)
|
||||||
|
{
|
||||||
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
||||||
|
Audit(agentId ?? "", action, details, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Audit(string agentId, string action, object details, string reason)
|
||||||
|
{
|
||||||
|
if (host.AuditRepository is not { } audit)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
audit.AppendAsync(new AuditEntry
|
||||||
|
{
|
||||||
|
RunId = "",
|
||||||
|
AgentId = agentId,
|
||||||
|
Source = "admin",
|
||||||
|
Tool = "Organisation",
|
||||||
|
Arguments = JsonSerializer.Serialize(new { action, details }),
|
||||||
|
Status = AuditStatus.Ok,
|
||||||
|
Summary = string.IsNullOrWhiteSpace(reason) ? action : $"{action}: {reason.Trim()}",
|
||||||
|
OccurredAt = DateTime.UtcNow
|
||||||
|
}, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// The change itself is saved; a missing audit line must not undo it.
|
||||||
|
logger.LogError(ex, "Organisation change {Action} could not be written to the audit log", action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,24 @@ public sealed class AgentConfig
|
|||||||
[JsonPropertyName("displayName")]
|
[JsonPropertyName("displayName")]
|
||||||
public string DisplayName { get; set; } = "";
|
public string DisplayName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>#683: The unit this agent belongs to; <c>null</c> for the top level.</summary>
|
||||||
|
[JsonPropertyName("unitId")]
|
||||||
|
public string? UnitId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: Since when the agent is in its unit (UTC) — so it can be told that it moved,
|
||||||
|
/// and its lead that someone joined.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("unitSinceUtc")]
|
||||||
|
public DateTime? UnitSinceUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: The unit the agent left with its last move — so the lead it left is told as
|
||||||
|
/// well as the one it joined. <c>null</c> after the first placement.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("previousUnitId")]
|
||||||
|
public string? PreviousUnitId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("model")]
|
[JsonPropertyName("model")]
|
||||||
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
|
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,13 @@ public sealed class InstanceConfig
|
|||||||
[JsonPropertyName("agents")]
|
[JsonPropertyName("agents")]
|
||||||
public List<AgentConfig> Agents { get; set; } = new();
|
public List<AgentConfig> Agents { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: Departments, teams, groups — the organisation tree. Empty means no structure:
|
||||||
|
/// every agent can reach every other, as before units existed.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("units")]
|
||||||
|
public List<UnitConfig> Units { get; set; } = new();
|
||||||
|
|
||||||
[JsonPropertyName("services")]
|
[JsonPropertyName("services")]
|
||||||
public List<ServiceConfig> Services { get; set; } = new();
|
public List<ServiceConfig> Services { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: A unit — department, team, group — as a node in the organisation tree.
|
||||||
|
///
|
||||||
|
/// <para><b>Stored in the instance configuration, next to the agents.</b> An agent's tools
|
||||||
|
/// already live there; a unit's rules belong beside them, so the truth about an agent is in
|
||||||
|
/// one place. Every structural change is written to the audit log by the administration,
|
||||||
|
/// which is what the concept asked the database for.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>No value here overrides an agent at run time</b> (Organisation-Konzept, section
|
||||||
|
/// 3). <see cref="Defaults"/> are only the starting values for agents that join;
|
||||||
|
/// <see cref="ToolCeiling"/> is checked when an agent's tools are written, never laid over
|
||||||
|
/// them while it runs. What stands at the agent is what applies.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UnitConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>The parent unit; <c>null</c> for a top-level unit.</summary>
|
||||||
|
[JsonPropertyName("parentId")]
|
||||||
|
public string? ParentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The agent leading this unit — one of its members. May be empty: then the nearest
|
||||||
|
/// unit above that has a lead takes over.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("leaderAgentId")]
|
||||||
|
public string? LeaderAgentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>What the unit exists for — the top of every task chain in it (#714).</summary>
|
||||||
|
[JsonPropertyName("goal")]
|
||||||
|
public string Goal { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tools members of this unit may have at most; <c>null</c> means no ceiling. A
|
||||||
|
/// ceiling applies to sub-units as well — the effective ceiling is the intersection
|
||||||
|
/// along the path to the root.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("toolCeiling")]
|
||||||
|
public List<string>? ToolCeiling { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Starting values for agents that join this unit.</summary>
|
||||||
|
[JsonPropertyName("defaults")]
|
||||||
|
public UnitDefaults Defaults { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: What an agent gets when it joins a unit. <c>null</c> leaves the agent's own value.
|
||||||
|
/// Changing these does not touch existing members — writing a value into them is a
|
||||||
|
/// separate, previewed step.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UnitDefaults
|
||||||
|
{
|
||||||
|
[JsonPropertyName("model")]
|
||||||
|
public string? Model { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("dailyCostUsd")]
|
||||||
|
public decimal? DailyCostUsd { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("dailyTokens")]
|
||||||
|
public long? DailyTokens { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("whenExhausted")]
|
||||||
|
public string? WhenExhausted { get; set; }
|
||||||
|
}
|
||||||
@@ -41,6 +41,24 @@ 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>
|
||||||
|
/// #683: The organisation tree, built fresh from the configuration on each call. Without
|
||||||
|
/// it, the instance has no structure and every agent reaches every other, as before.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Organisation.OrgChart>? OrganisationProvider { get; set; }
|
||||||
|
|
||||||
|
private Organisation.OrgChart CurrentOrganisation()
|
||||||
|
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
|
||||||
|
|
||||||
|
/// <summary>The agent's system prompt with its organisation section (#683).</summary>
|
||||||
|
private string SystemPromptFor(AgentConfig agentConfig, bool hasTools)
|
||||||
|
=> Organisation.OrgBriefing.WithSection(
|
||||||
|
UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools),
|
||||||
|
Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime));
|
||||||
|
|
||||||
/// <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 +197,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);
|
||||||
@@ -221,7 +241,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
var messages = new List<ChatMessage>();
|
var messages = new List<ChatMessage>();
|
||||||
|
|
||||||
// #193: Die Regel zu gerahmten Werkzeugergebnissen gehoert zum festen Teil.
|
// #193: Die Regel zu gerahmten Werkzeugergebnissen gehoert zum festen Teil.
|
||||||
var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
|
// #683: dazu der Abschnitt zur Organisation.
|
||||||
|
var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
|
||||||
if (!string.IsNullOrWhiteSpace(systemPrompt))
|
if (!string.IsNullOrWhiteSpace(systemPrompt))
|
||||||
messages.Add(ChatMessage.System(systemPrompt));
|
messages.Add(ChatMessage.System(systemPrompt));
|
||||||
|
|
||||||
@@ -498,8 +519,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);
|
||||||
@@ -548,11 +572,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
messages = new List<ChatMessage>();
|
messages = new List<ChatMessage>();
|
||||||
// #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren
|
// #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren
|
||||||
// Systemprompt; die Regel kommt mit dem naechsten neuen Kontext.
|
// Systemprompt; die Regel kommt mit dem naechsten neuen Kontext.
|
||||||
var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
|
var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
|
||||||
if (!string.IsNullOrWhiteSpace(systemPrompt))
|
if (!string.IsNullOrWhiteSpace(systemPrompt))
|
||||||
messages.Add(ChatMessage.System(systemPrompt));
|
messages.Add(ChatMessage.System(systemPrompt));
|
||||||
_chatContexts[agentConfig.AgentId] = messages;
|
_chatContexts[agentConfig.AgentId] = messages;
|
||||||
}
|
}
|
||||||
|
else if (messages.Count > 0 && messages[0].Role == "system")
|
||||||
|
{
|
||||||
|
// #683: Nur der Organisationsabschnitt folgt der Struktur — nach einem
|
||||||
|
// Umzug weiss der Agent sonst im laufenden Chat nichts davon. Der Rest
|
||||||
|
// des bestehenden Systemprompts bleibt, wie er war (#193).
|
||||||
|
var section = Organisation.OrgBriefing.For(
|
||||||
|
CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime);
|
||||||
|
var refreshed = Organisation.OrgBriefing.WithSection(messages[0].Content ?? "", section);
|
||||||
|
if (refreshed != messages[0].Content)
|
||||||
|
messages[0] = ChatMessage.System(refreshed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt
|
// Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt
|
||||||
@@ -1044,19 +1079,41 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
$"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " +
|
$"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " +
|
||||||
"kann keinen zweiten Auftrag annehmen, ohne sich selbst zu blockieren.";
|
"kann keinen zweiten Auftrag annehmen, ohne sich selbst zu blockieren.";
|
||||||
|
|
||||||
if (callerChain.Depth >= MaxDelegationDepth)
|
// #683: Die Grenze folgt der Tiefe des Baums; der eingestellte Wert bleibt Untergrenze.
|
||||||
|
var maxDepth = CurrentOrganisation().DelegationDepth(MaxDelegationDepth);
|
||||||
|
if (callerChain.Depth >= maxDepth)
|
||||||
return $"Delegationstiefe erschöpft: {callerChain} hat die Grenze von " +
|
return $"Delegationstiefe erschöpft: {callerChain} hat die Grenze von " +
|
||||||
$"{MaxDelegationDepth} Weitergaben erreicht. Erledige den Rest selbst " +
|
$"{maxDepth} Weitergaben erreicht. Erledige den Rest selbst " +
|
||||||
"oder gib die Aufgabe an den Auftraggeber zurück.";
|
"oder gib die Aufgabe an den Auftraggeber zurück.";
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: The address book, enforced — not merely described in the prompt. A refused
|
||||||
|
/// contact is an answer to the caller, naming the way that is open: its lead.
|
||||||
|
/// </summary>
|
||||||
|
private string? CheckAddressBook(string fromAgentId, string toAgentId)
|
||||||
|
{
|
||||||
|
var org = CurrentOrganisation();
|
||||||
|
if (org.CanAddress(fromAgentId, toAgentId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var lead = org.EffectiveLeaderOf(fromAgentId);
|
||||||
|
return $"'{toAgentId}' is not in your address book: from your position in the organisation you " +
|
||||||
|
"cannot reach this agent directly. " +
|
||||||
|
(lead is not null
|
||||||
|
? $"Hand the matter to your lead '{lead.AgentId}', who can pass it on."
|
||||||
|
: "Create a task for @human if it needs someone outside your reach.");
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<AgentInfo> ListAgents(string callerAgentId)
|
public IReadOnlyList<AgentInfo> ListAgents(string callerAgentId)
|
||||||
{
|
{
|
||||||
var configs = _agentConfigProvider?.Invoke() ?? [];
|
var configs = _agentConfigProvider?.Invoke() ?? [];
|
||||||
|
// #683: Only the agent's address book — the cut of the organisation.
|
||||||
|
var reachable = CurrentOrganisation().AddressBook(callerAgentId);
|
||||||
return configs
|
return configs
|
||||||
.Where(a => a.AgentId != callerAgentId)
|
.Where(a => a.AgentId != callerAgentId && reachable.Contains(a.AgentId))
|
||||||
.Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId)))
|
.Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId)))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
@@ -1089,6 +1146,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
if (targetConfig is null)
|
if (targetConfig is null)
|
||||||
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
|
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
|
||||||
|
|
||||||
|
if (CheckAddressBook(fromAgentId, toAgentId) is { } outOfReach)
|
||||||
|
return new AgentMessageResult(false, null, outOfReach);
|
||||||
|
|
||||||
if (CheckDelegation(chain, fromAgentId, toAgentId) is { } refused)
|
if (CheckDelegation(chain, fromAgentId, toAgentId) is { } refused)
|
||||||
return new AgentMessageResult(false, null, refused);
|
return new AgentMessageResult(false, null, refused);
|
||||||
|
|
||||||
@@ -1141,6 +1201,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
|
|
||||||
// Auch der Spawn zählt: Er startet einen vollen Lauf und kann seinerseits
|
// Auch der Spawn zählt: Er startet einen vollen Lauf und kann seinerseits
|
||||||
// delegieren. Ohne diese Prüfung wäre die Kette über spawn zu umgehen.
|
// delegieren. Ohne diese Prüfung wäre die Kette über spawn zu umgehen.
|
||||||
|
if (CheckAddressBook(fromAgentId, targetAgentId) is { } outOfReach)
|
||||||
|
return new AgentSpawnResult(false, targetAgentId, null, outOfReach);
|
||||||
|
|
||||||
if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused)
|
if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused)
|
||||||
return new AgentSpawnResult(false, targetAgentId, null, refused);
|
return new AgentSpawnResult(false, targetAgentId, null, refused);
|
||||||
|
|
||||||
@@ -1598,7 +1661,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
this,
|
this,
|
||||||
_memoryRepository,
|
_memoryRepository,
|
||||||
_taskRepository,
|
_taskRepository,
|
||||||
chain ?? DelegationChain.Root(agentConfig.AgentId));
|
chain ?? DelegationChain.Root(agentConfig.AgentId))
|
||||||
|
{
|
||||||
|
Organisation = CurrentOrganisation()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Organisation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: The "Organisation" section of an agent's system prompt — its unit, its lead, whom it
|
||||||
|
/// can reach, and what changed recently.
|
||||||
|
///
|
||||||
|
/// <para><b>Why the code writes it and not the agent's own texts.</b> The concept demands
|
||||||
|
/// that agents are told when they move, and warns: an agent that has to infer its lead from
|
||||||
|
/// a prompt escalates to the wrong place. A section generated from the tree is always true —
|
||||||
|
/// a move changes it by itself, for the agent that moved and for both leads.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Without units there is no section</b>, so instances without a structure keep
|
||||||
|
/// exactly the prompt they had — and the prompt cache that goes with it.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class OrgBriefing
|
||||||
|
{
|
||||||
|
/// <summary>Starts the section; also how an existing chat context finds it to replace it.</summary>
|
||||||
|
public const string Heading = "# Organisation";
|
||||||
|
|
||||||
|
/// <summary>How long a move or a new colleague is pointed out.</summary>
|
||||||
|
public static readonly TimeSpan RecentChange = TimeSpan.FromDays(7);
|
||||||
|
|
||||||
|
private const int MaxDescription = 120;
|
||||||
|
|
||||||
|
public static string? For(OrgChart org, string agentId, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (!org.HasUnits || org.Agent(agentId) is not { } agent)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine(Heading);
|
||||||
|
|
||||||
|
var unit = org.UnitOf(agentId);
|
||||||
|
if (unit is null)
|
||||||
|
{
|
||||||
|
sb.AppendLine("You are on the top level of this instance, above all units.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append($"You belong to the unit \"{unit.Name}\" ({org.PathOf(unit.Id)})");
|
||||||
|
if (agent.UnitSinceUtc is { } since)
|
||||||
|
{
|
||||||
|
sb.Append($", since {since:yyyy-MM-dd}");
|
||||||
|
if (nowUtc - since < RecentChange)
|
||||||
|
sb.Append(" — this is recent: your lead, your colleagues and possibly your tools changed");
|
||||||
|
}
|
||||||
|
sb.AppendLine(".");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(unit.Goal))
|
||||||
|
sb.AppendLine($"Goal of your unit: {unit.Goal.Trim()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var lead = org.EffectiveLeaderOf(agentId);
|
||||||
|
if (lead is not null)
|
||||||
|
sb.AppendLine($"Your lead: {Name(lead)}. Questions, problems and anything outside your unit go to your lead.");
|
||||||
|
else
|
||||||
|
sb.AppendLine("There is no lead agent above you. What you cannot decide goes to a human: create a task for @human.");
|
||||||
|
|
||||||
|
if (org.LedBy(agentId) is { } led)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"You lead the unit \"{led.Name}\". Delegate within it, and hand what goes beyond it to your own lead.");
|
||||||
|
var subUnits = org.Children(led.Id);
|
||||||
|
if (subUnits.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("Its sub-units: " + string.Join("; ", subUnits.Select(u =>
|
||||||
|
org.LeaderOf(u.Id) is { } l ? $"{u.Name} (lead: {l.AgentId})" : $"{u.Name} (no lead — its members report to you)")) + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
var joined = org.MembersOf(led.Id)
|
||||||
|
.Where(m => m.AgentId != agentId && m.UnitSinceUtc is { } s && nowUtc - s < RecentChange)
|
||||||
|
.ToList();
|
||||||
|
if (joined.Count > 0)
|
||||||
|
sb.AppendLine("New in your unit: " + string.Join(", ", joined.Select(m => $"{m.AgentId} since {m.UnitSinceUtc:yyyy-MM-dd}")) + ".");
|
||||||
|
|
||||||
|
// The lead that was left is told as well as the one that was joined.
|
||||||
|
var left = org.Agents
|
||||||
|
.Where(a => string.Equals(a.PreviousUnitId, led.Id, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& a.UnitSinceUtc is { } s && nowUtc - s < RecentChange)
|
||||||
|
.ToList();
|
||||||
|
if (left.Count > 0)
|
||||||
|
sb.AppendLine("Left your unit recently: " + string.Join(", ", left.Select(a =>
|
||||||
|
$"{a.AgentId} (now in {(org.UnitOf(a.AgentId) is { } u ? u.Name : "the top level")})")) +
|
||||||
|
". Hand over what is still open with them.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var contacts = org.AddressBook(agentId)
|
||||||
|
.Where(id => lead is null || !string.Equals(id, lead.AgentId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(id => org.Agent(id)!)
|
||||||
|
.OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (contacts.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("You can also reach:");
|
||||||
|
foreach (var contact in contacts)
|
||||||
|
sb.AppendLine($"- {Name(contact)}{Describe(contact.Description)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append("Agents not named here cannot be reached from your position — go through your lead.");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the organisation section of a system prompt, appends it, or removes it —
|
||||||
|
/// leaving everything before it untouched. Existing chat contexts keep their system
|
||||||
|
/// prompt otherwise (#193); only this section follows the structure.
|
||||||
|
/// </summary>
|
||||||
|
public static string WithSection(string systemPrompt, string? section)
|
||||||
|
{
|
||||||
|
var start = systemPrompt.IndexOf("\n\n" + Heading + "\n", StringComparison.Ordinal);
|
||||||
|
var basePrompt = start >= 0 ? systemPrompt[..start] : systemPrompt;
|
||||||
|
if (start < 0 && systemPrompt.StartsWith(Heading + "\n", StringComparison.Ordinal))
|
||||||
|
basePrompt = "";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(section))
|
||||||
|
return basePrompt;
|
||||||
|
return basePrompt.Length == 0 ? section : basePrompt + "\n\n" + section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Name(Config.AgentConfig agent)
|
||||||
|
=> string.IsNullOrWhiteSpace(agent.DisplayName) || agent.DisplayName == agent.AgentId
|
||||||
|
? agent.AgentId
|
||||||
|
: $"{agent.DisplayName} ({agent.AgentId})";
|
||||||
|
|
||||||
|
private static string Describe(string? description)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(description))
|
||||||
|
return "";
|
||||||
|
var line = description.ReplaceLineEndings(" ").Trim();
|
||||||
|
return " — " + (line.Length > MaxDescription ? line[..MaxDescription] + "…" : line);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Organisation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: The organisation tree of an instance — units, members, leads — and everything that
|
||||||
|
/// follows from it: who an agent can reach, who its lead is, how deep delegation may go,
|
||||||
|
/// which tools a unit's members may have at most.
|
||||||
|
///
|
||||||
|
/// <para><b>One rule holds it together: an agent knows only its unit and its lead.</b>
|
||||||
|
/// The cut of the address book is the organisation (Organisation-Konzept, section 3). It
|
||||||
|
/// is computed from the tree, never configured, so it cannot be widened by accident —
|
||||||
|
/// whoever wants an agent to reach further moves it in the tree, visibly.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Pure.</b> Built from the configuration, no I/O, no state. Every question is
|
||||||
|
/// answered from the snapshot it was built from, so the engine, the administration and
|
||||||
|
/// the tests all see the same organisation.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Without units nothing changes:</b> every agent reaches every other, as before.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrgChart
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, UnitConfig> _units;
|
||||||
|
private readonly Dictionary<string, AgentConfig> _agents;
|
||||||
|
private readonly Dictionary<string, List<UnitConfig>> _children;
|
||||||
|
private readonly Dictionary<string, List<AgentConfig>> _members;
|
||||||
|
|
||||||
|
/// <summary>The key under which top-level units and agents without a unit are grouped.</summary>
|
||||||
|
private const string Root = "";
|
||||||
|
|
||||||
|
public OrgChart(IEnumerable<UnitConfig> units, IEnumerable<AgentConfig> agents)
|
||||||
|
{
|
||||||
|
_units = new Dictionary<string, UnitConfig>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var unit in units)
|
||||||
|
_units.TryAdd(unit.Id, unit);
|
||||||
|
|
||||||
|
_agents = new Dictionary<string, AgentConfig>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var agent in agents)
|
||||||
|
_agents.TryAdd(agent.AgentId, agent);
|
||||||
|
|
||||||
|
_children = new Dictionary<string, List<UnitConfig>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var unit in _units.Values)
|
||||||
|
{
|
||||||
|
var parent = KnownParent(unit) ?? Root;
|
||||||
|
if (!_children.TryGetValue(parent, out var list))
|
||||||
|
_children[parent] = list = [];
|
||||||
|
list.Add(unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
_members = new Dictionary<string, List<AgentConfig>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var agent in _agents.Values)
|
||||||
|
{
|
||||||
|
var key = KnownUnit(agent) ?? Root;
|
||||||
|
if (!_members.TryGetValue(key, out var list))
|
||||||
|
_members[key] = list = [];
|
||||||
|
list.Add(agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents);
|
||||||
|
|
||||||
|
/// <summary>Whether the instance has any structure at all.</summary>
|
||||||
|
public bool HasUnits => _units.Count > 0;
|
||||||
|
|
||||||
|
public IReadOnlyCollection<UnitConfig> Units => _units.Values;
|
||||||
|
|
||||||
|
public IReadOnlyCollection<AgentConfig> Agents => _agents.Values;
|
||||||
|
|
||||||
|
public UnitConfig? Unit(string? unitId)
|
||||||
|
=> unitId is not null && _units.TryGetValue(unitId, out var unit) ? unit : null;
|
||||||
|
|
||||||
|
public AgentConfig? Agent(string agentId) => _agents.GetValueOrDefault(agentId);
|
||||||
|
|
||||||
|
/// <summary>The unit an agent belongs to; <c>null</c> for the top level (or an unknown unit).</summary>
|
||||||
|
public UnitConfig? UnitOf(string agentId)
|
||||||
|
=> _agents.TryGetValue(agentId, out var agent) ? Unit(KnownUnit(agent)) : null;
|
||||||
|
|
||||||
|
/// <summary>Direct sub-units; <c>null</c> asks for the top-level units.</summary>
|
||||||
|
public IReadOnlyList<UnitConfig> Children(string? unitId)
|
||||||
|
=> _children.TryGetValue(unitId ?? Root, out var list) ? list : [];
|
||||||
|
|
||||||
|
/// <summary>Direct members; <c>null</c> asks for the agents on the top level.</summary>
|
||||||
|
public IReadOnlyList<AgentConfig> MembersOf(string? unitId)
|
||||||
|
=> _members.TryGetValue(unitId ?? Root, out var list) ? list : [];
|
||||||
|
|
||||||
|
/// <summary>The units above, nearest first — not including the unit itself.</summary>
|
||||||
|
public IReadOnlyList<UnitConfig> Ancestors(string unitId)
|
||||||
|
{
|
||||||
|
var result = new List<UnitConfig>();
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { unitId };
|
||||||
|
var current = Unit(unitId);
|
||||||
|
while (current is not null && KnownParent(current) is { } parentId && seen.Add(parentId))
|
||||||
|
{
|
||||||
|
current = _units[parentId];
|
||||||
|
result.Add(current);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"Marketing / Marketplaces / Amazon".</summary>
|
||||||
|
public string PathOf(string unitId)
|
||||||
|
{
|
||||||
|
if (Unit(unitId) is not { } unit)
|
||||||
|
return "";
|
||||||
|
var names = Ancestors(unitId).Select(u => u.Name).Reverse().Append(unit.Name);
|
||||||
|
return string.Join(" / ", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Depth of a unit: a top-level unit is 1.</summary>
|
||||||
|
public int Depth(string unitId) => Unit(unitId) is null ? 0 : Ancestors(unitId).Count + 1;
|
||||||
|
|
||||||
|
/// <summary>The deepest unit's depth; 0 without units.</summary>
|
||||||
|
public int TreeDepth => _units.Keys.Select(Depth).DefaultIfEmpty(0).Max();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many hand-overs a delegation chain may have: enough to go from the top to the
|
||||||
|
/// bottom of the tree and one step further, and never less than the configured floor.
|
||||||
|
/// Before #683 this was a fixed 3 — lead, department, team, agent already used it up.
|
||||||
|
/// </summary>
|
||||||
|
public int DelegationDepth(int configuredFloor) => Math.Max(configuredFloor, TreeDepth + 1);
|
||||||
|
|
||||||
|
/// <summary>The lead of a unit, if it has one that is actually a member.</summary>
|
||||||
|
public AgentConfig? LeaderOf(string? unitId)
|
||||||
|
{
|
||||||
|
if (Unit(unitId) is not { LeaderAgentId: { Length: > 0 } leaderId } unit)
|
||||||
|
return null;
|
||||||
|
return _agents.TryGetValue(leaderId, out var leader)
|
||||||
|
&& string.Equals(KnownUnit(leader), unit.Id, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? leader
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The unit this agent leads, if any.</summary>
|
||||||
|
public UnitConfig? LedBy(string agentId)
|
||||||
|
{
|
||||||
|
var unit = UnitOf(agentId);
|
||||||
|
return unit is not null && LeaderOf(unit.Id) is { } leader
|
||||||
|
&& string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? unit
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Who the agent answers to: the lead of its unit — or, if it leads that unit itself or
|
||||||
|
/// the unit has no lead, the nearest lead above. <c>null</c> means: the human.
|
||||||
|
/// </summary>
|
||||||
|
public AgentConfig? EffectiveLeaderOf(string agentId)
|
||||||
|
{
|
||||||
|
var unit = UnitOf(agentId);
|
||||||
|
if (unit is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
foreach (var candidate in new[] { unit }.Concat(Ancestors(unit.Id)))
|
||||||
|
{
|
||||||
|
if (LeaderOf(candidate.Id) is { } leader
|
||||||
|
&& !string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return leader;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everyone the agent may address — the cut of the address book (section 3):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>the members of its own unit and its effective lead;</item>
|
||||||
|
/// <item>as a lead: the leads of its sub-units — and, where a sub-unit has no lead, that
|
||||||
|
/// sub-unit's members, because this lead is who they answer to — and the leads of its
|
||||||
|
/// sibling units;</item>
|
||||||
|
/// <item>on the top level: each other and the leads of the top-level units, both ways.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Without units: everyone.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlySet<string> AddressBook(string agentId)
|
||||||
|
{
|
||||||
|
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (!_agents.ContainsKey(agentId))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
if (!HasUnits)
|
||||||
|
{
|
||||||
|
result.UnionWith(_agents.Keys);
|
||||||
|
result.Remove(agentId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unit = UnitOf(agentId);
|
||||||
|
foreach (var member in MembersOf(unit?.Id))
|
||||||
|
result.Add(member.AgentId);
|
||||||
|
|
||||||
|
if (EffectiveLeaderOf(agentId) is { } lead)
|
||||||
|
result.Add(lead.AgentId);
|
||||||
|
|
||||||
|
if (unit is null)
|
||||||
|
{
|
||||||
|
// Top level: the house management reaches the heads of the top-level units.
|
||||||
|
AddLeadsBelow(null, result);
|
||||||
|
}
|
||||||
|
else if (LedBy(agentId) is { } led)
|
||||||
|
{
|
||||||
|
AddLeadsBelow(led.Id, result);
|
||||||
|
|
||||||
|
foreach (var sibling in Children(KnownParent(led)))
|
||||||
|
{
|
||||||
|
if (!string.Equals(sibling.Id, led.Id, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& LeaderOf(sibling.Id) is { } siblingLead)
|
||||||
|
result.Add(siblingLead.AgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A top-level lead reaches the top level — the same line, seen from below.
|
||||||
|
if (KnownParent(led) is null)
|
||||||
|
foreach (var member in MembersOf(null))
|
||||||
|
result.Add(member.AgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Remove(agentId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanAddress(string fromAgentId, string toAgentId)
|
||||||
|
=> AddressBook(fromAgentId).Contains(toAgentId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tools a member of the unit may have at most: the intersection of the ceilings
|
||||||
|
/// along the path to the top. <c>null</c> means no ceiling anywhere on the path.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlySet<string>? ToolCeiling(string? unitId)
|
||||||
|
{
|
||||||
|
if (Unit(unitId) is not { } unit)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
HashSet<string>? ceiling = null;
|
||||||
|
foreach (var node in new[] { unit }.Concat(Ancestors(unit.Id)))
|
||||||
|
{
|
||||||
|
if (node.ToolCeiling is null)
|
||||||
|
continue;
|
||||||
|
if (ceiling is null)
|
||||||
|
ceiling = new HashSet<string>(node.ToolCeiling, StringComparer.OrdinalIgnoreCase);
|
||||||
|
else
|
||||||
|
ceiling.IntersectWith(node.ToolCeiling);
|
||||||
|
}
|
||||||
|
return ceiling;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What is wrong with the structure — empty when it is sound. Checked when the
|
||||||
|
/// administration writes and when an instance starts; the tree is still usable with
|
||||||
|
/// problems (unknown references count as top level), but they are named.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Problems()
|
||||||
|
{
|
||||||
|
var problems = new List<string>();
|
||||||
|
|
||||||
|
foreach (var unit in _units.Values)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(unit.Id))
|
||||||
|
problems.Add("A unit has no id.");
|
||||||
|
if (unit.ParentId is { Length: > 0 } parentId && !_units.ContainsKey(parentId))
|
||||||
|
problems.Add($"Unit '{unit.Id}' names the parent '{parentId}', which does not exist.");
|
||||||
|
if (IsInCycle(unit.Id))
|
||||||
|
problems.Add($"Unit '{unit.Id}' is part of a cycle — a unit cannot contain itself.");
|
||||||
|
if (unit.LeaderAgentId is { Length: > 0 } leaderId && LeaderOf(unit.Id) is null)
|
||||||
|
problems.Add(_agents.ContainsKey(leaderId)
|
||||||
|
? $"Unit '{unit.Id}' names '{leaderId}' as lead, but that agent is not a member of it."
|
||||||
|
: $"Unit '{unit.Id}' names the lead '{leaderId}', which does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var agent in _agents.Values)
|
||||||
|
{
|
||||||
|
if (agent.UnitId is { Length: > 0 } unitId && !_units.ContainsKey(unitId))
|
||||||
|
problems.Add($"Agent '{agent.AgentId}' belongs to the unit '{unitId}', which does not exist.");
|
||||||
|
|
||||||
|
if (ToolCeiling(KnownUnit(agent)) is { } ceiling)
|
||||||
|
{
|
||||||
|
var beyond = agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList();
|
||||||
|
if (beyond.Count > 0)
|
||||||
|
problems.Add($"Agent '{agent.AgentId}' has tools beyond its unit's ceiling: {string.Join(", ", beyond)}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ───
|
||||||
|
|
||||||
|
private void AddLeadsBelow(string? unitId, HashSet<string> into)
|
||||||
|
{
|
||||||
|
foreach (var child in Children(unitId))
|
||||||
|
{
|
||||||
|
if (LeaderOf(child.Id) is { } lead)
|
||||||
|
{
|
||||||
|
into.Add(lead.AgentId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No lead there: its members answer to the lead above — so this one sees them,
|
||||||
|
// and looks further down for the next leads.
|
||||||
|
foreach (var member in MembersOf(child.Id))
|
||||||
|
into.Add(member.AgentId);
|
||||||
|
AddLeadsBelow(child.Id, into);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The parent id if it names an existing unit and does not close a cycle.</summary>
|
||||||
|
private string? KnownParent(UnitConfig unit)
|
||||||
|
=> unit.ParentId is { Length: > 0 } parentId && _units.ContainsKey(parentId) && !IsInCycle(unit.Id)
|
||||||
|
? _units[parentId].Id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private string? KnownUnit(AgentConfig agent)
|
||||||
|
=> agent.UnitId is { Length: > 0 } unitId && _units.TryGetValue(unitId, out var unit) ? unit.Id : null;
|
||||||
|
|
||||||
|
private bool IsInCycle(string unitId)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var current = unitId;
|
||||||
|
while (current is not null && _units.TryGetValue(current, out var unit))
|
||||||
|
{
|
||||||
|
if (!seen.Add(current))
|
||||||
|
return true;
|
||||||
|
current = unit.ParentId is { Length: > 0 } p ? p : null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,4 +34,11 @@ public sealed record AgentToolContext(
|
|||||||
/// Ohne Freigabestelle bleibt es <c>false</c>, und das Werkzeug prueft selbst.
|
/// Ohne Freigabestelle bleibt es <c>false</c>, und das Werkzeug prueft selbst.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool FreigabeGeprueft { get; init; }
|
public bool FreigabeGeprueft { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: The organisation at the time of the call. Tools that let an agent address
|
||||||
|
/// another — a task assignment, for instance — check the address book here, so it cannot
|
||||||
|
/// be walked around. <c>null</c> in tests and outside the engine: no structure, no limit.
|
||||||
|
/// </summary>
|
||||||
|
public Organisation.OrgChart? Organisation { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1721,3 +1721,190 @@ 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Organisation (#683) ─── */
|
||||||
|
|
||||||
|
.org-ansicht {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-raster {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 360px) 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-baum {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-baum-kopf {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-knoten-liste {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-ebene-titel {
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.org-knoten {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
border: none;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
padding-top: 5px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.org-knoten.aktiv {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.org-einheit .org-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-detail .karte {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-grund input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-werkzeuge {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-werkzeug {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-einstellung {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-liste {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.org-ausreisser td {
|
||||||
|
background: rgba(181, 130, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-probleme {
|
||||||
|
border: 1px solid var(--gefahr);
|
||||||
|
border-left-width: 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-probleme ul {
|
||||||
|
margin: 6px 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.org-raster { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,10 +35,13 @@ 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";
|
||||||
|
import { renderOrganisation } from "./views/organisation.js";
|
||||||
|
|
||||||
// ─── 1. Routen registrieren ───
|
// ─── 1. Routen registrieren ───
|
||||||
|
|
||||||
function verlasseAnsicht() {
|
function verlasseAnsicht() {
|
||||||
|
leaveTeam();
|
||||||
trenneLogStream();
|
trenneLogStream();
|
||||||
trenneAgentenChatsStream();
|
trenneAgentenChatsStream();
|
||||||
stoppeInstanzenPolling();
|
stoppeInstanzenPolling();
|
||||||
@@ -50,6 +53,8 @@ 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("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); });
|
||||||
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,87 @@
|
|||||||
"nav.chat": "Chat",
|
"nav.chat": "Chat",
|
||||||
"nav.agents": "Agenten",
|
"nav.agents": "Agenten",
|
||||||
"nav.tasks": "Aufgaben",
|
"nav.tasks": "Aufgaben",
|
||||||
|
"nav.team": "Teamübersicht",
|
||||||
|
"common.saved": "Gespeichert.",
|
||||||
|
"common.create": "Anlegen",
|
||||||
|
"nav.organisation": "Organisation",
|
||||||
|
"org.title": "Organisation",
|
||||||
|
"org.subtitle": "Einheiten, Leitungen und Mitglieder. Jeder Agent erreicht nur seine Einheit und seine Leitung — die Struktur ist das Adressbuch.",
|
||||||
|
"org.problems": "Probleme in der Struktur",
|
||||||
|
"org.structure": "Struktur",
|
||||||
|
"org.newUnit": "Neue Einheit",
|
||||||
|
"org.newSubUnit": "Neue Untereinheit",
|
||||||
|
"org.topLevel": "Oberste Ebene",
|
||||||
|
"org.leadShort": "Leitung",
|
||||||
|
"org.unitId": "Kennung (Buchstaben, Ziffern, - und _)",
|
||||||
|
"org.unitName": "Name",
|
||||||
|
"org.parent": "Übergeordnete Einheit",
|
||||||
|
"org.goal": "Ziel der Einheit",
|
||||||
|
"org.reason": "Begründung der Änderung (geht ins Audit)",
|
||||||
|
"org.reasonPlaceholder": "z. B. Marktplätze bekommen ein eigenes Team",
|
||||||
|
"org.selectHint": "Links eine Einheit oder einen Agenten wählen.",
|
||||||
|
"org.lead": "Leitung",
|
||||||
|
"org.noLead": "— keine Leitung (die Leitung darüber übernimmt) —",
|
||||||
|
"org.leadHint": "Die Leitung muss Mitglied der Einheit sein.",
|
||||||
|
"org.position": "Platz im Baum",
|
||||||
|
"org.move": "Verschieben",
|
||||||
|
"org.moveUnitHint": "Die Mitglieder bleiben in der Einheit; es ändert sich, was darüber liegt. Nicht möglich, solange ein betroffener Agent läuft.",
|
||||||
|
"org.ceiling": "Rechte-Obergrenze",
|
||||||
|
"org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.",
|
||||||
|
"org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen",
|
||||||
|
"org.ceilingInherited": "Von oben begrenzt auf: {tools}",
|
||||||
|
"org.settings": "Sammelwerte",
|
||||||
|
"org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.",
|
||||||
|
"org.setting.DailyCostUsd": "Tagesbudget (USD)",
|
||||||
|
"org.setting.DailyTokens": "Tagesbudget (Tokens)",
|
||||||
|
"org.setting.Model": "Modell",
|
||||||
|
"org.setting.WhenExhausted": "Wenn das Budget erschöpft ist",
|
||||||
|
"org.modePerAgent": "jeder Agent bekommt den Wert",
|
||||||
|
"org.modeDistribute": "Wert ist ein Gesamtbetrag, verteilt",
|
||||||
|
"org.preview": "Vorschau",
|
||||||
|
"org.apply": "Gewählte Werte schreiben",
|
||||||
|
"org.agent": "Agent",
|
||||||
|
"org.current": "Bisher",
|
||||||
|
"org.proposed": "Neu",
|
||||||
|
"org.outlier": "Ausreißer",
|
||||||
|
"org.outlierHint": "Ausreißer weichen stark von ihren Kollegen ab — oft ein Spezialist mit bewusst anderem Wert. Sie sind standardmäßig nicht gewählt.",
|
||||||
|
"org.rememberDefault": "Als Vorgabe für neue Mitglieder merken",
|
||||||
|
"org.settingInvalid": "Dieser Wert ist für diese Einstellung nicht gültig.",
|
||||||
|
"org.members": "Mitglieder",
|
||||||
|
"org.noMembers": "Noch keine Mitglieder.",
|
||||||
|
"org.deleteUnit": "Einheit löschen",
|
||||||
|
"org.unit": "Einheit",
|
||||||
|
"org.answersTo": "Berichtet an",
|
||||||
|
"org.human": "einen Menschen",
|
||||||
|
"org.since": "In dieser Einheit seit {time}",
|
||||||
|
"org.tools": "Werkzeuge",
|
||||||
|
"org.moveAgent": "Agent verschieben",
|
||||||
|
"org.moveAgentHint": "Adressbuch, Leitung und womöglich Werkzeuge ändern sich. Der Agent und beide Leitungen erfahren es in ihrem nächsten Lauf.",
|
||||||
|
"org.toolsLostTitle": "Werkzeuge würden entfernt",
|
||||||
|
"org.toolsLostIntro": "Der neue Platz hat eine Rechte-Obergrenze. Diese Agenten verlören Werkzeuge:",
|
||||||
|
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
|
||||||
|
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
|
||||||
|
"team.title": "Teamübersicht",
|
||||||
|
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
|
||||||
|
"team.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,87 @@
|
|||||||
"nav.chat": "Chat",
|
"nav.chat": "Chat",
|
||||||
"nav.agents": "Agents",
|
"nav.agents": "Agents",
|
||||||
"nav.tasks": "Tasks",
|
"nav.tasks": "Tasks",
|
||||||
|
"nav.team": "Team overview",
|
||||||
|
"common.saved": "Saved.",
|
||||||
|
"common.create": "Create",
|
||||||
|
"nav.organisation": "Organisation",
|
||||||
|
"org.title": "Organisation",
|
||||||
|
"org.subtitle": "Units, leads and members. Every agent only reaches its unit and its lead — the structure is the address book.",
|
||||||
|
"org.problems": "Problems in the structure",
|
||||||
|
"org.structure": "Structure",
|
||||||
|
"org.newUnit": "New unit",
|
||||||
|
"org.newSubUnit": "New sub-unit",
|
||||||
|
"org.topLevel": "Top level",
|
||||||
|
"org.leadShort": "lead",
|
||||||
|
"org.unitId": "Id (letters, digits, - and _)",
|
||||||
|
"org.unitName": "Name",
|
||||||
|
"org.parent": "Parent unit",
|
||||||
|
"org.goal": "Goal of the unit",
|
||||||
|
"org.reason": "Reason for the change (goes into the audit log)",
|
||||||
|
"org.reasonPlaceholder": "e.g. marketplaces get their own team",
|
||||||
|
"org.selectHint": "Select a unit or an agent on the left.",
|
||||||
|
"org.lead": "Lead",
|
||||||
|
"org.noLead": "— no lead (the lead above takes over) —",
|
||||||
|
"org.leadHint": "The lead must be a member of the unit.",
|
||||||
|
"org.position": "Position in the tree",
|
||||||
|
"org.move": "Move",
|
||||||
|
"org.moveUnitHint": "The members stay in the unit; what changes is what lies above it. Not possible while an affected agent is running.",
|
||||||
|
"org.ceiling": "Tool ceiling",
|
||||||
|
"org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.",
|
||||||
|
"org.ceilingLimit": "Limit the tools of this unit",
|
||||||
|
"org.ceilingInherited": "Limited from above to: {tools}",
|
||||||
|
"org.settings": "Unit-wide values",
|
||||||
|
"org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.",
|
||||||
|
"org.setting.DailyCostUsd": "Daily budget (USD)",
|
||||||
|
"org.setting.DailyTokens": "Daily budget (tokens)",
|
||||||
|
"org.setting.Model": "Model",
|
||||||
|
"org.setting.WhenExhausted": "When the budget is exhausted",
|
||||||
|
"org.modePerAgent": "each agent gets the value",
|
||||||
|
"org.modeDistribute": "value is a total, distributed",
|
||||||
|
"org.preview": "Preview",
|
||||||
|
"org.apply": "Write the selected values",
|
||||||
|
"org.agent": "Agent",
|
||||||
|
"org.current": "Current",
|
||||||
|
"org.proposed": "New",
|
||||||
|
"org.outlier": "outlier",
|
||||||
|
"org.outlierHint": "Outliers differ strongly from their colleagues — often a specialist with a deliberately different value. They are not selected by default.",
|
||||||
|
"org.rememberDefault": "Remember as default for new members",
|
||||||
|
"org.settingInvalid": "That value is not valid for this setting.",
|
||||||
|
"org.members": "Members",
|
||||||
|
"org.noMembers": "No members yet.",
|
||||||
|
"org.deleteUnit": "Delete unit",
|
||||||
|
"org.unit": "Unit",
|
||||||
|
"org.answersTo": "Answers to",
|
||||||
|
"org.human": "a human",
|
||||||
|
"org.since": "In this unit since {time}",
|
||||||
|
"org.tools": "Tools",
|
||||||
|
"org.moveAgent": "Move agent",
|
||||||
|
"org.moveAgentHint": "Its address book, its lead and possibly its tools change. The agent and both leads are told in their next run.",
|
||||||
|
"org.toolsLostTitle": "Tools would be removed",
|
||||||
|
"org.toolsLostIntro": "The new position has a tool ceiling. These agents would lose tools:",
|
||||||
|
"org.toolsLostConfirm": "Remove the tools and apply",
|
||||||
|
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
|
||||||
|
"team.title": "Team overview",
|
||||||
|
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
|
||||||
|
"team.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,18 @@
|
|||||||
</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="#/organisation" class="nav-link" title="Organisation" data-i18n-title="nav.organisation">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
|
<path d="M9 2h6v5H9z M3 17h6v5H3z M15 17h6v5h-6z M12 7v5 M6 17v-5h12v5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label" data-i18n="nav.organisation">Organisation</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,471 @@
|
|||||||
|
// ClawdDotNet — Organisation (#683)
|
||||||
|
//
|
||||||
|
// Units, leads, members — and the three things that change rights: moving, the tool
|
||||||
|
// ceiling, unit-wide values. Every rule is the instance's (OrgAdministration); this page
|
||||||
|
// asks for a preview first wherever something could be lost, and shows it before writing.
|
||||||
|
//
|
||||||
|
// A list, not a graph: this is where the structure is edited. Watching it work is the team
|
||||||
|
// overview (#711).
|
||||||
|
|
||||||
|
import { api } from "../modules/api.js";
|
||||||
|
import { erstelleElement, erstelleKarte, erstellePlakette, erstelleModal, zeigeMeldung, formatiereDatumZeit } from "../modules/dom.js";
|
||||||
|
import { t } from "../modules/i18n.js";
|
||||||
|
|
||||||
|
const SETTINGS = ["DailyCostUsd", "DailyTokens", "Model", "WhenExhausted"];
|
||||||
|
const NUMERIC = new Set(["DailyCostUsd", "DailyTokens"]);
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
let selection = null; // { kind: "unit" | "agent", id }
|
||||||
|
let treeBox = null;
|
||||||
|
let detailBox = null;
|
||||||
|
let reasonInput = null;
|
||||||
|
|
||||||
|
export async function renderOrganisation() {
|
||||||
|
const container = document.getElementById("ansicht-inhalt");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
treeBox = erstelleElement("div", { klasse: "org-baum" });
|
||||||
|
detailBox = erstelleElement("div", { klasse: "org-detail" });
|
||||||
|
|
||||||
|
container.replaceChildren(erstelleElement(
|
||||||
|
"div",
|
||||||
|
{ klasse: "org-ansicht" },
|
||||||
|
erstelleElement("h1", { text: t("org.title") }),
|
||||||
|
erstelleElement("p", { klasse: "gedaempft", text: t("org.subtitle") }),
|
||||||
|
erstelleElement("div", { id: "org-probleme" }),
|
||||||
|
erstelleElement("div", { klasse: "org-raster" }, treeBox, detailBox)
|
||||||
|
));
|
||||||
|
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
data = await api("/organisation");
|
||||||
|
} catch (err) {
|
||||||
|
treeBox.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection && !find(selection)) selection = null;
|
||||||
|
renderProblems();
|
||||||
|
renderTree();
|
||||||
|
renderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ───
|
||||||
|
|
||||||
|
function find(sel) {
|
||||||
|
if (!sel || !data) return null;
|
||||||
|
return sel.kind === "unit"
|
||||||
|
? data.units.find((u) => u.id === sel.id)
|
||||||
|
: data.agents.find((a) => a.agentId === sel.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function childrenOf(unitId) {
|
||||||
|
return data.units.filter((u) => (u.parentId ?? null) === unitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function membersOf(unitId) {
|
||||||
|
return data.agents.filter((a) => (a.unitId ?? null) === unitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function descendantIds(unitId) {
|
||||||
|
const ids = new Set([unitId]);
|
||||||
|
let grew = true;
|
||||||
|
while (grew) {
|
||||||
|
grew = false;
|
||||||
|
for (const u of data.units) {
|
||||||
|
if (u.parentId && ids.has(u.parentId) && !ids.has(u.id)) { ids.add(u.id); grew = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reason() {
|
||||||
|
return reasonInput?.value.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(path, method, body, successText) {
|
||||||
|
try {
|
||||||
|
const result = await api(path, { method, body });
|
||||||
|
zeigeMeldung(result?.message ?? successText ?? t("common.saved"), "erfolg");
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
zeigeMeldung(err.message, "gefahr", 8000);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves and ceiling changes: ask for a preview, stop on problems or running agents, show
|
||||||
|
* the tools that would be lost and let the human confirm — only then write.
|
||||||
|
*/
|
||||||
|
async function withPreview(path, method, body) {
|
||||||
|
let preview;
|
||||||
|
try {
|
||||||
|
preview = await api(path, { method, body: { ...body, preview: true } });
|
||||||
|
} catch (err) {
|
||||||
|
zeigeMeldung(err.message, "gefahr", 8000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.problems.length) { zeigeMeldung(preview.problems.join(" "), "gefahr", 8000); return; }
|
||||||
|
if (preview.running.length) {
|
||||||
|
zeigeMeldung(t("org.runningRefusal", { agents: preview.running.join(", ") }), "warnung", 8000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lost = Object.entries(preview.toolsLost ?? {});
|
||||||
|
if (!lost.length) {
|
||||||
|
await send(path, method, { ...body, preview: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = erstelleElement("ul", { klasse: "org-liste" },
|
||||||
|
...lost.map(([agent, tools]) => erstelleElement("li", { text: `${agent}: ${tools.join(", ")}` })));
|
||||||
|
const content = erstelleElement("div", {},
|
||||||
|
erstelleElement("p", { text: t("org.toolsLostIntro") }), list);
|
||||||
|
|
||||||
|
const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") });
|
||||||
|
const confirm = erstelleElement("button", { type: "button", klasse: "gefahr", text: t("org.toolsLostConfirm") });
|
||||||
|
const modal = erstelleModal(t("org.toolsLostTitle"), content, [cancel, confirm]);
|
||||||
|
cancel.addEventListener("click", () => modal.schliessen());
|
||||||
|
confirm.addEventListener("click", async () => {
|
||||||
|
modal.schliessen();
|
||||||
|
await send(path, method, { ...body, preview: false, dropExcessTools: true });
|
||||||
|
});
|
||||||
|
modal.oeffnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitOptions(select, current, exclude = new Set(), topLabel = t("org.topLevel")) {
|
||||||
|
select.append(erstelleElement("option", { value: "", text: topLabel }));
|
||||||
|
for (const unit of data.units) {
|
||||||
|
if (exclude.has(unit.id)) continue;
|
||||||
|
const option = erstelleElement("option", { value: unit.id, text: unit.path });
|
||||||
|
if (unit.id === current) option.selected = true;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
if (!current) select.value = "";
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── problems ───
|
||||||
|
|
||||||
|
function renderProblems() {
|
||||||
|
const box = document.getElementById("org-probleme");
|
||||||
|
if (!box) return;
|
||||||
|
box.replaceChildren(...(data.problems.length
|
||||||
|
? [erstelleElement("div", { klasse: "org-probleme" },
|
||||||
|
erstelleElement("strong", { text: t("org.problems") }),
|
||||||
|
erstelleElement("ul", {}, ...data.problems.map((p) => erstelleElement("li", { text: p }))))]
|
||||||
|
: []));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tree ───
|
||||||
|
|
||||||
|
function renderTree() {
|
||||||
|
const newUnit = erstelleElement("button", {
|
||||||
|
type: "button", klasse: "primaer klein", text: t("org.newUnit"), onclick: () => openCreateUnit()
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = erstelleElement("div", { klasse: "org-knoten-liste" });
|
||||||
|
const root = erstelleElement("div", { klasse: "org-ebene" },
|
||||||
|
erstelleElement("div", { klasse: "org-ebene-titel gedaempft text-klein", text: t("org.topLevel") }));
|
||||||
|
for (const agent of membersOf(null)) root.append(agentRow(agent, 0));
|
||||||
|
list.append(root);
|
||||||
|
for (const unit of childrenOf(null)) list.append(unitBlock(unit, 0));
|
||||||
|
|
||||||
|
treeBox.replaceChildren(
|
||||||
|
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
|
||||||
|
erstelleElement("h2", { text: t("org.structure") }), newUnit),
|
||||||
|
list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitBlock(unit, depth) {
|
||||||
|
const lead = unit.leaderAgentId ? ` · ${t("org.leadShort")}: ${unit.leaderAgentId}` : "";
|
||||||
|
const count = membersOf(unit.id).length;
|
||||||
|
const row = erstelleElement("button", {
|
||||||
|
type: "button",
|
||||||
|
klasse: `org-knoten org-einheit ${selection?.kind === "unit" && selection.id === unit.id ? "aktiv" : ""}`,
|
||||||
|
stil: `padding-left: ${12 + depth * 18}px`,
|
||||||
|
onclick: () => select({ kind: "unit", id: unit.id })
|
||||||
|
},
|
||||||
|
erstelleElement("span", { klasse: "org-name", text: `▣ ${unit.name}` }),
|
||||||
|
erstelleElement("span", { klasse: "gedaempft text-klein", text: `${count}${lead}` }));
|
||||||
|
|
||||||
|
const block = erstelleElement("div", {}, row);
|
||||||
|
for (const agent of membersOf(unit.id)) block.append(agentRow(agent, depth + 1));
|
||||||
|
for (const child of childrenOf(unit.id)) block.append(unitBlock(child, depth + 1));
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRow(agent, depth) {
|
||||||
|
return erstelleElement("button", {
|
||||||
|
type: "button",
|
||||||
|
klasse: `org-knoten org-agent ${selection?.kind === "agent" && selection.id === agent.agentId ? "aktiv" : ""}`,
|
||||||
|
stil: `padding-left: ${12 + depth * 18}px`,
|
||||||
|
onclick: () => select({ kind: "agent", id: agent.agentId })
|
||||||
|
},
|
||||||
|
erstelleElement("span", { text: `${agent.isLead ? "★ " : "· "}${agent.displayName || agent.agentId}` }),
|
||||||
|
erstelleElement("span", { klasse: "gedaempft text-klein", text: agent.agentId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(sel) {
|
||||||
|
selection = sel;
|
||||||
|
renderTree();
|
||||||
|
renderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateUnit(parentId = null) {
|
||||||
|
const id = erstelleElement("input", { type: "text", placeholder: "marketing", required: true });
|
||||||
|
const name = erstelleElement("input", { type: "text", placeholder: "Marketing", required: true });
|
||||||
|
const parent = unitOptions(erstelleElement("select"), parentId);
|
||||||
|
const goal = erstelleElement("textarea", { rows: 2 });
|
||||||
|
|
||||||
|
const form = erstelleElement("form", { klasse: "formular-raster" },
|
||||||
|
erstelleElement("label", { text: t("org.unitId") }), id,
|
||||||
|
erstelleElement("label", { text: t("org.unitName") }), name,
|
||||||
|
erstelleElement("label", { text: t("org.parent") }), parent,
|
||||||
|
erstelleElement("label", { text: t("org.goal") }), goal);
|
||||||
|
|
||||||
|
const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") });
|
||||||
|
const create = erstelleElement("button", { type: "submit", klasse: "primaer", text: t("common.create") });
|
||||||
|
const modal = erstelleModal(t("org.newUnit"), form, [cancel, create]);
|
||||||
|
cancel.addEventListener("click", () => modal.schliessen());
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const ok = await send("/organisation/units", "POST", {
|
||||||
|
id: id.value.trim(), name: name.value.trim(), parentId: parent.value || null, goal: goal.value, reason: reason()
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
modal.schliessen();
|
||||||
|
select({ kind: "unit", id: id.value.trim() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modal.oeffnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── detail ───
|
||||||
|
|
||||||
|
function renderDetail() {
|
||||||
|
const item = find(selection);
|
||||||
|
reasonInput = erstelleElement("input", { type: "text", placeholder: t("org.reasonPlaceholder") });
|
||||||
|
const reasonCard = erstelleKarte(null, [
|
||||||
|
erstelleElement("label", { klasse: "text-klein gedaempft", text: t("org.reason") }), reasonInput
|
||||||
|
], { klasse: "org-grund" });
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
detailBox.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailBox.replaceChildren(reasonCard, ...(selection.kind === "unit" ? unitPanel(item) : agentPanel(item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitPanel(unit) {
|
||||||
|
const cards = [];
|
||||||
|
|
||||||
|
// Name and goal.
|
||||||
|
const name = erstelleElement("input", { type: "text", value: unit.name });
|
||||||
|
const goal = erstelleElement("textarea", { rows: 2 });
|
||||||
|
goal.value = unit.goal ?? "";
|
||||||
|
cards.push(erstelleKarte(unit.path, [
|
||||||
|
erstelleElement("div", { klasse: "formular-raster" },
|
||||||
|
erstelleElement("label", { text: t("org.unitName") }), name,
|
||||||
|
erstelleElement("label", { text: t("org.goal") }), goal,
|
||||||
|
erstelleElement("button", {
|
||||||
|
type: "button", klasse: "primaer klein", text: t("common.save"),
|
||||||
|
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}`, "PATCH",
|
||||||
|
{ name: name.value, goal: goal.value, reason: reason() })
|
||||||
|
}))
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Lead.
|
||||||
|
const lead = erstelleElement("select", {}, erstelleElement("option", { value: "", text: t("org.noLead") }));
|
||||||
|
for (const member of membersOf(unit.id)) {
|
||||||
|
const option = erstelleElement("option", { value: member.agentId, text: member.displayName || member.agentId });
|
||||||
|
if (member.agentId === unit.leaderAgentId) option.selected = true;
|
||||||
|
lead.append(option);
|
||||||
|
}
|
||||||
|
cards.push(erstelleKarte(t("org.lead"), [
|
||||||
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.leadHint") }),
|
||||||
|
erstelleElement("div", { klasse: "gruppe-horizontal" }, lead,
|
||||||
|
erstelleElement("button", {
|
||||||
|
type: "button", klasse: "klein", text: t("common.save"),
|
||||||
|
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/leader`, "PUT",
|
||||||
|
{ agentId: lead.value || null, reason: reason() })
|
||||||
|
}))
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Position in the tree.
|
||||||
|
const parent = unitOptions(erstelleElement("select"), unit.parentId, descendantIds(unit.id));
|
||||||
|
cards.push(erstelleKarte(t("org.position"), [
|
||||||
|
erstelleElement("div", { klasse: "gruppe-horizontal" }, parent,
|
||||||
|
erstelleElement("button", {
|
||||||
|
type: "button", klasse: "klein", text: t("org.move"),
|
||||||
|
onclick: () => withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/move`, "POST",
|
||||||
|
{ target: parent.value || null, reason: reason() })
|
||||||
|
})),
|
||||||
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveUnitHint") })
|
||||||
|
]));
|
||||||
|
|
||||||
|
cards.push(ceilingCard(unit));
|
||||||
|
cards.push(settingsCard(unit));
|
||||||
|
|
||||||
|
// Members.
|
||||||
|
const members = membersOf(unit.id);
|
||||||
|
cards.push(erstelleKarte(t("org.members"), [
|
||||||
|
members.length
|
||||||
|
? erstelleElement("ul", { klasse: "org-liste" }, ...members.map((m) => erstelleElement("li", {},
|
||||||
|
erstelleElement("button", {
|
||||||
|
type: "button", klasse: "flach klein", text: `${m.isLead ? "★ " : ""}${m.displayName || m.agentId}`,
|
||||||
|
onclick: () => select({ kind: "agent", id: m.agentId })
|
||||||
|
}))))
|
||||||
|
: erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.noMembers") }),
|
||||||
|
erstelleElement("div", { klasse: "gruppe-horizontal" },
|
||||||
|
erstelleElement("button", {
|
||||||
|
type: "button", klasse: "flach klein", text: t("org.newSubUnit"), onclick: () => openCreateUnit(unit.id)
|
||||||
|
}),
|
||||||
|
erstelleElement("button", {
|
||||||
|
type: "button", klasse: "gefahr klein", text: t("org.deleteUnit"),
|
||||||
|
onclick: async () => {
|
||||||
|
if (await send(`/organisation/units/${encodeURIComponent(unit.id)}?reason=${encodeURIComponent(reason())}`, "DELETE")) {
|
||||||
|
selection = null;
|
||||||
|
renderDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]));
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ceilingCard(unit) {
|
||||||
|
const limited = erstelleElement("input", { type: "checkbox" });
|
||||||
|
limited.checked = Array.isArray(unit.toolCeiling);
|
||||||
|
const boxes = data.knownTools.map((tool) => {
|
||||||
|
const box = erstelleElement("input", { type: "checkbox", value: tool });
|
||||||
|
// Pre-selected: the unit's own ceiling — or, without one, what is allowed from above anyway.
|
||||||
|
const allowed = unit.toolCeiling ?? unit.effectiveToolCeiling;
|
||||||
|
box.checked = !allowed || allowed.includes(tool);
|
||||||
|
return erstelleElement("label", { klasse: "org-werkzeug" }, box, ` ${tool}`);
|
||||||
|
});
|
||||||
|
const grid = erstelleElement("div", { klasse: "org-werkzeuge" }, ...boxes);
|
||||||
|
const sync = () => { grid.hidden = !limited.checked; };
|
||||||
|
limited.addEventListener("change", sync);
|
||||||
|
sync();
|
||||||
|
|
||||||
|
const inherited = unit.effectiveToolCeiling && !unit.toolCeiling
|
||||||
|
? erstelleElement("p", { klasse: "text-klein", text: t("org.ceilingInherited", { tools: unit.effectiveToolCeiling.join(", ") }) })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return erstelleKarte(t("org.ceiling"), [
|
||||||
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.ceilingHint") }),
|
||||||
|
inherited,
|
||||||
|
erstelleElement("label", { klasse: "gruppe-horizontal" }, limited, t("org.ceilingLimit")),
|
||||||
|
grid,
|
||||||
|
erstelleElement("button", {
|
||||||
|
type: "button", klasse: "klein", text: t("common.save"),
|
||||||
|
onclick: () => {
|
||||||
|
const tools = limited.checked
|
||||||
|
? [...grid.querySelectorAll("input:checked")].map((b) => b.value)
|
||||||
|
: null;
|
||||||
|
withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/ceiling`, "PUT", { tools, reason: reason() });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsCard(unit) {
|
||||||
|
const setting = erstelleElement("select", {},
|
||||||
|
...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) })));
|
||||||
|
const value = erstelleElement("input", { type: "text", placeholder: "10" });
|
||||||
|
const mode = erstelleElement("select", {},
|
||||||
|
erstelleElement("option", { value: "PerAgent", text: t("org.modePerAgent") }),
|
||||||
|
erstelleElement("option", { value: "Distribute", text: t("org.modeDistribute") }));
|
||||||
|
const remember = erstelleElement("input", { type: "checkbox" });
|
||||||
|
remember.checked = true;
|
||||||
|
const result = erstelleElement("div");
|
||||||
|
|
||||||
|
setting.addEventListener("change", () => { mode.disabled = !NUMERIC.has(setting.value); });
|
||||||
|
|
||||||
|
const preview = async () => {
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = await api(`/organisation/units/${encodeURIComponent(unit.id)}/settings/preview`, {
|
||||||
|
method: "POST", body: { setting: setting.value, value: value.value.trim(), mode: mode.disabled ? "PerAgent" : mode.value }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
zeigeMeldung(err.message, "gefahr", 8000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rows.length) { result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: t("org.settingInvalid") })); return; }
|
||||||
|
|
||||||
|
const checks = new Map();
|
||||||
|
const table = erstelleElement("table", { klasse: "daten-tabelle" },
|
||||||
|
erstelleElement("thead", {}, erstelleElement("tr", {},
|
||||||
|
...["", t("org.agent"), t("org.current"), t("org.proposed"), ""].map((h) => erstelleElement("th", { text: h })))),
|
||||||
|
erstelleElement("tbody", {}, ...rows.map((row) => {
|
||||||
|
const box = erstelleElement("input", { type: "checkbox" });
|
||||||
|
box.checked = row.selectedByDefault;
|
||||||
|
checks.set(row.agentId, { box, value: row.proposed });
|
||||||
|
return erstelleElement("tr", { klasse: row.outlier ? "org-ausreisser" : "" },
|
||||||
|
erstelleElement("td", {}, box),
|
||||||
|
erstelleElement("td", { text: row.displayName || row.agentId }),
|
||||||
|
erstelleElement("td", { text: row.current }),
|
||||||
|
erstelleElement("td", { text: row.proposed }),
|
||||||
|
erstelleElement("td", {}, row.outlier ? erstellePlakette(`⚠ ${t("org.outlier")}`, "warnung") : ""));
|
||||||
|
})));
|
||||||
|
|
||||||
|
const apply = erstelleElement("button", {
|
||||||
|
type: "button", klasse: "primaer klein", text: t("org.apply"),
|
||||||
|
onclick: () => {
|
||||||
|
const values = {};
|
||||||
|
for (const [agentId, { box, value: v }] of checks) if (box.checked) values[agentId] = v;
|
||||||
|
send(`/organisation/units/${encodeURIComponent(unit.id)}/settings`, "POST", {
|
||||||
|
setting: setting.value, values, reason: reason(),
|
||||||
|
defaultValue: remember.checked && mode.value === "PerAgent" ? value.value.trim() : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
result.replaceChildren(
|
||||||
|
rows.some((r) => r.outlier) ? erstelleElement("p", { klasse: "text-klein", text: `⚠ ${t("org.outlierHint")}` }) : "",
|
||||||
|
table,
|
||||||
|
erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, remember, t("org.rememberDefault")),
|
||||||
|
apply);
|
||||||
|
};
|
||||||
|
|
||||||
|
return erstelleKarte(t("org.settings"), [
|
||||||
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.settingsHint") }),
|
||||||
|
erstelleElement("div", { klasse: "gruppe-horizontal org-einstellung" }, setting, value, mode,
|
||||||
|
erstelleElement("button", { type: "button", klasse: "klein", text: t("org.preview"), onclick: preview })),
|
||||||
|
result
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentPanel(agent) {
|
||||||
|
const unit = data.units.find((u) => u.id === agent.unitId);
|
||||||
|
const target = unitOptions(erstelleElement("select"), agent.unitId);
|
||||||
|
|
||||||
|
return [
|
||||||
|
erstelleKarte(agent.displayName || agent.agentId, [
|
||||||
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${agent.agentId} · ${agent.model}` }),
|
||||||
|
erstelleElement("p", { text: `${t("org.unit")}: ${unit ? unit.path : t("org.topLevel")}${agent.isLead ? ` (★ ${t("org.lead")})` : ""}` }),
|
||||||
|
erstelleElement("p", { text: `${t("org.answersTo")}: ${agent.effectiveLeadId ?? t("org.human")}` }),
|
||||||
|
agent.unitSinceUtc ? erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.since", { time: formatiereDatumZeit(agent.unitSinceUtc) }) }) : null,
|
||||||
|
erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` })
|
||||||
|
]),
|
||||||
|
erstelleKarte(t("org.moveAgent"), [
|
||||||
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveAgentHint") }),
|
||||||
|
erstelleElement("div", { klasse: "gruppe-horizontal" }, target,
|
||||||
|
erstelleElement("button", {
|
||||||
|
type: "button", klasse: "klein", text: t("org.move"),
|
||||||
|
onclick: () => withPreview(`/organisation/agents/${encodeURIComponent(agent.agentId)}/move`, "POST",
|
||||||
|
{ target: target.value || null, reason: reason() })
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -151,6 +151,12 @@ public sealed class TaskboardTool : IAgentTool
|
|||||||
if (string.IsNullOrWhiteSpace(action))
|
if (string.IsNullOrWhiteSpace(action))
|
||||||
return ToolResult.Fail("'action' is required.");
|
return ToolResult.Fail("'action' is required.");
|
||||||
|
|
||||||
|
// #683: A task for another agent is a way of reaching it — the same address book
|
||||||
|
// applies as for a message, otherwise it could be walked around.
|
||||||
|
if (action is "task_create" or "task_update"
|
||||||
|
&& CheckAssignee(GetString(input, "assignee"), context) is { } outOfReach)
|
||||||
|
return ToolResult.Fail(outOfReach);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return action switch
|
return action switch
|
||||||
@@ -170,6 +176,26 @@ public sealed class TaskboardTool : IAgentTool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? CheckAssignee(string? assignee, AgentToolContext context)
|
||||||
|
{
|
||||||
|
if (context.Organisation is not { HasUnits: true } org || string.IsNullOrWhiteSpace(assignee))
|
||||||
|
return null;
|
||||||
|
if (TaskAssignee.KindOf(assignee) == TaskAssigneeKind.Human)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var target = TaskAssignee.AgentId(assignee);
|
||||||
|
if (target.Length == 0 || string.Equals(target, context.AgentId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return null;
|
||||||
|
if (org.CanAddress(context.AgentId, target))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var lead = org.EffectiveLeaderOf(context.AgentId);
|
||||||
|
return $"'{target}' is not in your address book, so you cannot assign tasks to it. " +
|
||||||
|
(lead is not null
|
||||||
|
? $"Assign it to your lead '{lead.AgentId}', who can pass it on."
|
||||||
|
: "Assign it to @human.");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Aktionen ───
|
// ─── Aktionen ───
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public sealed record ServiceDto(
|
|||||||
public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = "");
|
public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = "");
|
||||||
|
|
||||||
/// <summary>Ein neuer Agent.</summary>
|
/// <summary>Ein neuer Agent.</summary>
|
||||||
public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "");
|
public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "", string? UnitId = null);
|
||||||
|
|
||||||
/// <summary>Eine Aufgabe vom Taskboard.</summary>
|
/// <summary>Eine Aufgabe vom Taskboard.</summary>
|
||||||
public sealed record TaskDto(
|
public sealed record TaskDto(
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
namespace ClawdDotNet.Web.Contracts;
|
||||||
|
|
||||||
|
/// <summary>#683: The organisation of an instance — units, members, and what is wrong with it.</summary>
|
||||||
|
public sealed record OrganisationDto(
|
||||||
|
IReadOnlyList<UnitDto> Units,
|
||||||
|
IReadOnlyList<OrgMemberDto> Agents,
|
||||||
|
IReadOnlyList<string> Problems,
|
||||||
|
IReadOnlyList<string> KnownTools);
|
||||||
|
|
||||||
|
public sealed record UnitDto(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string? ParentId,
|
||||||
|
string? LeaderAgentId,
|
||||||
|
string Goal,
|
||||||
|
IReadOnlyList<string>? ToolCeiling,
|
||||||
|
IReadOnlyList<string>? EffectiveToolCeiling,
|
||||||
|
UnitDefaultsDto Defaults,
|
||||||
|
string Path,
|
||||||
|
int Depth);
|
||||||
|
|
||||||
|
public sealed record UnitDefaultsDto(string? Model, decimal? DailyCostUsd, long? DailyTokens, string? WhenExhausted);
|
||||||
|
|
||||||
|
/// <summary>An agent as the organisation page shows it: where it is, whom it answers to.</summary>
|
||||||
|
public sealed record OrgMemberDto(
|
||||||
|
string AgentId,
|
||||||
|
string DisplayName,
|
||||||
|
string? UnitId,
|
||||||
|
bool IsLead,
|
||||||
|
string? EffectiveLeadId,
|
||||||
|
DateTime? UnitSinceUtc,
|
||||||
|
IReadOnlyList<string> Tools,
|
||||||
|
decimal DailyCostUsd,
|
||||||
|
long DailyTokens,
|
||||||
|
string Model);
|
||||||
|
|
||||||
|
public sealed record UnitInputDto(string Id, string Name, string? ParentId = null, string? Goal = null, string Reason = "");
|
||||||
|
|
||||||
|
public sealed record UnitUpdateDto(string? Name = null, string? Goal = null, string Reason = "");
|
||||||
|
|
||||||
|
public sealed record LeaderInputDto(string? AgentId, string Reason = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A move or a ceiling change. With <see cref="Preview"/> nothing is written — the answer
|
||||||
|
/// says what would happen. <see cref="DropExcessTools"/> confirms removing the tools beyond a
|
||||||
|
/// ceiling that the preview named.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MoveInputDto(string? Target, string Reason = "", bool DropExcessTools = false, bool Preview = false);
|
||||||
|
|
||||||
|
public sealed record CeilingInputDto(IReadOnlyList<string>? Tools, string Reason = "", bool DropExcessTools = false, bool Preview = false);
|
||||||
|
|
||||||
|
public sealed record StructurePreviewDto(
|
||||||
|
IReadOnlyList<string> AffectedAgents,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<string>> ToolsLost,
|
||||||
|
IReadOnlyList<string> Running,
|
||||||
|
IReadOnlyList<string> Problems,
|
||||||
|
bool CanApply);
|
||||||
|
|
||||||
|
public sealed record SettingPreviewInputDto(string Setting, string Value, string Mode = "PerAgent");
|
||||||
|
|
||||||
|
public sealed record SettingChangeDto(string AgentId, string DisplayName, string Current, string Proposed, bool Outlier, bool SelectedByDefault);
|
||||||
|
|
||||||
|
public sealed record SettingApplyDto(string Setting, IReadOnlyDictionary<string, string> Values, string Reason = "", string? DefaultValue = null);
|
||||||
@@ -31,6 +31,8 @@ internal static class ApiEndpoints
|
|||||||
api.MapUsage();
|
api.MapUsage();
|
||||||
api.MapAnalytics();
|
api.MapAnalytics();
|
||||||
api.MapLogs();
|
api.MapLogs();
|
||||||
|
api.MapOrganisation();
|
||||||
|
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
|
||||||
|
|
||||||
app.MapEvents();
|
app.MapEvents();
|
||||||
app.MapControl();
|
app.MapControl();
|
||||||
@@ -125,7 +127,7 @@ internal static class ApiEndpoints
|
|||||||
|
|
||||||
agents.MapPost("/", (AppHost host, NewAgentDto input) =>
|
agents.MapPost("/", (AppHost host, NewAgentDto input) =>
|
||||||
host.AgentAdmin.Add(new NewAgent(
|
host.AgentAdmin.Add(new NewAgent(
|
||||||
input.AgentId, input.DisplayName, input.Model, input.Description)).ToResponse());
|
input.AgentId, input.DisplayName, input.Model, input.Description, input.UnitId)).ToResponse());
|
||||||
|
|
||||||
agents.MapDelete("/{agentId}", (AppHost host, string agentId) =>
|
agents.MapDelete("/{agentId}", (AppHost host, string agentId) =>
|
||||||
host.AgentAdmin.Remove(agentId).ToResponse());
|
host.AgentAdmin.Remove(agentId).ToResponse());
|
||||||
|
|||||||
@@ -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,90 @@
|
|||||||
|
using ClawdDotNet.App;
|
||||||
|
using ClawdDotNet.App.Services;
|
||||||
|
using ClawdDotNet.Web.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Web.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: The organisation — units, leads, moves, tool ceilings, unit-wide values. Every
|
||||||
|
/// decision is made by <see cref="OrgAdministration"/>; this only translates.
|
||||||
|
/// </summary>
|
||||||
|
internal static class OrganisationEndpoints
|
||||||
|
{
|
||||||
|
public static void MapOrganisation(this RouteGroupBuilder api)
|
||||||
|
{
|
||||||
|
var org = api.MapGroup("/organisation");
|
||||||
|
|
||||||
|
org.MapGet("/", (AppHost host) =>
|
||||||
|
{
|
||||||
|
var chart = host.Organisation.Current();
|
||||||
|
var units = chart.Units
|
||||||
|
.OrderBy(u => chart.PathOf(u.Id), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(u => new UnitDto(
|
||||||
|
u.Id, u.Name, u.ParentId, u.LeaderAgentId, u.Goal, u.ToolCeiling,
|
||||||
|
chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||||
|
new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted),
|
||||||
|
chart.PathOf(u.Id), chart.Depth(u.Id)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var agents = host.Instance.Agents
|
||||||
|
.OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(a => new OrgMemberDto(
|
||||||
|
a.AgentId, a.DisplayName, chart.UnitOf(a.AgentId)?.Id, chart.LedBy(a.AgentId) is not null,
|
||||||
|
chart.EffectiveLeaderOf(a.AgentId)?.AgentId, a.UnitSinceUtc,
|
||||||
|
a.Tools.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||||
|
a.Budget.DailyCostUsd, a.Budget.DailyTokens, a.Model))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools);
|
||||||
|
});
|
||||||
|
|
||||||
|
org.MapPost("/units", (AppHost host, UnitInputDto input) =>
|
||||||
|
host.Organisation.CreateUnit(input.Id, input.Name, input.ParentId, input.Goal, input.Reason).ToResponse());
|
||||||
|
|
||||||
|
org.MapPatch("/units/{unitId}", (AppHost host, string unitId, UnitUpdateDto input) =>
|
||||||
|
host.Organisation.UpdateUnit(unitId, input.Name, input.Goal, input.Reason).ToResponse());
|
||||||
|
|
||||||
|
org.MapDelete("/units/{unitId}", (AppHost host, string unitId, string? reason) =>
|
||||||
|
host.Organisation.DeleteUnit(unitId, reason ?? "").ToResponse());
|
||||||
|
|
||||||
|
org.MapPut("/units/{unitId}/leader", (AppHost host, string unitId, LeaderInputDto input) =>
|
||||||
|
host.Organisation.SetLeader(unitId, input.AgentId, input.Reason).ToResponse());
|
||||||
|
|
||||||
|
org.MapPost("/units/{unitId}/move", (AppHost host, string unitId, MoveInputDto input) =>
|
||||||
|
input.Preview
|
||||||
|
? Results.Ok(ToDto(host.Organisation.PreviewMoveUnit(unitId, input.Target)))
|
||||||
|
: host.Organisation.MoveUnit(unitId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
|
||||||
|
|
||||||
|
org.MapPost("/agents/{agentId}/move", (AppHost host, string agentId, MoveInputDto input) =>
|
||||||
|
input.Preview
|
||||||
|
? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target)))
|
||||||
|
: host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
|
||||||
|
|
||||||
|
org.MapPut("/units/{unitId}/ceiling", (AppHost host, string unitId, CeilingInputDto input) =>
|
||||||
|
input.Preview
|
||||||
|
? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools)))
|
||||||
|
: host.Organisation.SetToolCeiling(unitId, input.Tools, input.Reason, input.DropExcessTools).ToResponse());
|
||||||
|
|
||||||
|
org.MapPost("/units/{unitId}/settings/preview", (AppHost host, string unitId, SettingPreviewInputDto input) =>
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<UnitSetting>(input.Setting, ignoreCase: true, out var setting)
|
||||||
|
|| !Enum.TryParse<UnitSettingMode>(input.Mode, ignoreCase: true, out var mode))
|
||||||
|
return Results.BadRequest(new OutcomeDto(false, "Unknown setting or mode."));
|
||||||
|
|
||||||
|
return Results.Ok(host.Organisation.PreviewSetting(unitId, setting, input.Value, mode)
|
||||||
|
.Select(c => new SettingChangeDto(c.AgentId, c.DisplayName, c.Current, c.Proposed, c.Outlier, c.SelectedByDefault))
|
||||||
|
.ToList());
|
||||||
|
});
|
||||||
|
|
||||||
|
org.MapPost("/units/{unitId}/settings", (AppHost host, string unitId, SettingApplyDto input) =>
|
||||||
|
Enum.TryParse<UnitSetting>(input.Setting, ignoreCase: true, out var setting)
|
||||||
|
? host.Organisation.ApplySetting(unitId, setting, input.Values, input.Reason, input.DefaultValue).ToResponse()
|
||||||
|
: Results.BadRequest(new OutcomeDto(false, "Unknown setting.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StructurePreviewDto ToDto(StructurePreview preview)
|
||||||
|
=> new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using ClawdDotNet.App.Services;
|
||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.App.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: Units in the administration — create, lead, move, limit, write unit-wide values.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrgAdministrationTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly TestHost _test;
|
||||||
|
|
||||||
|
public OrgAdministrationTests()
|
||||||
|
{
|
||||||
|
_test = new TestHost(
|
||||||
|
Agent("marketing-lead", "WebFetch", "AgentComm", "Taskboard"),
|
||||||
|
Agent("ads", "WebFetch", "Taskboard"),
|
||||||
|
Agent("listing", "WebFetch", "Taskboard"),
|
||||||
|
Agent("specialist", "WebFetch", "Taskboard", "Database"),
|
||||||
|
Agent("bookkeeper", "Database", "Taskboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _test.Dispose();
|
||||||
|
|
||||||
|
private static AgentConfig Agent(string id, params string[] tools)
|
||||||
|
=> new() { AgentId = id, DisplayName = id, Tools = tools.ToDictionary(t => t, _ => new Dictionary<string, object?>()) };
|
||||||
|
|
||||||
|
private OrgAdministration Org => _test.Host.Organisation;
|
||||||
|
|
||||||
|
private AgentConfig Find(string id) => _test.Host.AgentAdmin.Find(id)!;
|
||||||
|
|
||||||
|
private void BuildMarketing()
|
||||||
|
{
|
||||||
|
Org.CreateUnit("marketing", "Marketing", null, "Sell more.").Ok.ShouldBeTrue();
|
||||||
|
Org.CreateUnit("amazon", "Amazon", "marketing").Ok.ShouldBeTrue();
|
||||||
|
Org.MoveAgent("marketing-lead", "marketing", "setup").Ok.ShouldBeTrue();
|
||||||
|
Org.SetLeader("marketing", "marketing-lead").Ok.ShouldBeTrue();
|
||||||
|
foreach (var id in new[] { "ads", "listing", "specialist" })
|
||||||
|
Org.MoveAgent(id, "amazon", "setup").Ok.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ units ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_unit_needs_a_valid_id_a_name_and_an_existing_parent()
|
||||||
|
{
|
||||||
|
Org.CreateUnit("bad id", "X", null).Ok.ShouldBeFalse();
|
||||||
|
Org.CreateUnit("x", " ", null).Ok.ShouldBeFalse();
|
||||||
|
Org.CreateUnit("x", "X", "nowhere").Ok.ShouldBeFalse();
|
||||||
|
Org.CreateUnit("x", "X", null).Ok.ShouldBeTrue();
|
||||||
|
Org.CreateUnit("x", "Again", null).Ok.ShouldBeFalse("ids are unique");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_a_member_can_lead_a_unit()
|
||||||
|
{
|
||||||
|
Org.CreateUnit("marketing", "Marketing", null);
|
||||||
|
|
||||||
|
Org.SetLeader("marketing", "ads").Ok.ShouldBeFalse();
|
||||||
|
Org.MoveAgent("ads", "marketing", "join");
|
||||||
|
Org.SetLeader("marketing", "ads").Ok.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_an_empty_unit_can_be_deleted()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
|
||||||
|
Org.DeleteUnit("amazon").Ok.ShouldBeFalse("it has members");
|
||||||
|
foreach (var id in new[] { "ads", "listing", "specialist" })
|
||||||
|
Org.MoveAgent(id, null, "leave");
|
||||||
|
Org.DeleteUnit("amazon").Ok.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ moves ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_move_records_since_when_and_where_from()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
|
||||||
|
Org.MoveAgent("ads", "marketing", "promoted").Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
var ads = Find("ads");
|
||||||
|
ads.UnitId.ShouldBe("marketing");
|
||||||
|
ads.PreviousUnitId.ShouldBe("amazon");
|
||||||
|
ads.UnitSinceUtc.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_lead_that_moves_leaves_its_unit_without_lead()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
|
||||||
|
Org.MoveAgent("marketing-lead", "amazon", "reorg").Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
Org.Current().LeaderOf("marketing").ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_unit_cannot_be_moved_under_itself()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
|
||||||
|
Org.MoveUnit("marketing", "amazon", "oops").Ok.ShouldBeFalse();
|
||||||
|
Org.MoveUnit("marketing", "marketing", "oops").Ok.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_moved_unit_takes_its_members_along_and_tells_them()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
Org.CreateUnit("marketplaces", "Marketplaces", "marketing");
|
||||||
|
var before = Find("ads").UnitSinceUtc;
|
||||||
|
|
||||||
|
Org.MoveUnit("amazon", "marketplaces", "a new level in between").Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
Find("ads").UnitId.ShouldBe("amazon", "the unit is the same");
|
||||||
|
Org.Current().PathOf("amazon").ShouldBe("Marketing / Marketplaces / Amazon");
|
||||||
|
Find("ads").UnitSinceUtc.ShouldNotBe(before, "the position changed, so the briefing says so");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ tool ceiling ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_move_under_a_lower_ceiling_is_refused_until_confirmed()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeFalse(
|
||||||
|
"the specialist has Database — that has to be confirmed via the preview first");
|
||||||
|
|
||||||
|
var preview = Org.PreviewToolCeiling("amazon", ["WebFetch", "Taskboard"]);
|
||||||
|
preview.ToolsLost.Keys.ShouldBe(["specialist"]);
|
||||||
|
preview.ToolsLost["specialist"].ShouldBe(["Database"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Lowering_a_ceiling_removes_the_tools_beyond_it_from_the_members()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
Org.MoveAgent("specialist", null, "out first").Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
// And from now on it cannot be granted.
|
||||||
|
var refused = _test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true);
|
||||||
|
refused.Ok.ShouldBeFalse();
|
||||||
|
refused.Message.ShouldContain("ceiling");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Moving_into_a_ceiling_needs_the_tools_beyond_it_dropped_explicitly()
|
||||||
|
{
|
||||||
|
Org.CreateUnit("amazon", "Amazon", null);
|
||||||
|
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "setup").Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
var refused = Org.MoveAgent("specialist", "amazon", "join");
|
||||||
|
refused.Ok.ShouldBeFalse();
|
||||||
|
refused.Message.ShouldContain("Database");
|
||||||
|
Find("specialist").UnitId.ShouldBeNull("nothing happened");
|
||||||
|
|
||||||
|
Org.MoveAgent("specialist", "amazon", "join", dropExcessTools: true).Ok.ShouldBeTrue();
|
||||||
|
Find("specialist").Tools.Keys.ShouldNotContain("Database");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_ceiling_higher_up_limits_the_units_below()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
Org.MoveAgent("specialist", null, "out first");
|
||||||
|
Org.SetToolCeiling("marketing", ["WebFetch", "Taskboard", "AgentComm"], "setup").Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
_test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true).Ok.ShouldBeFalse(
|
||||||
|
"amazon has no ceiling of its own, but marketing above it has");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ unit-wide values ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_unit_wide_budget_marks_the_specialist_as_an_outlier_and_leaves_it_unselected()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
Find("ads").Budget.DailyCostUsd = 5;
|
||||||
|
Find("listing").Budget.DailyCostUsd = 4;
|
||||||
|
Find("specialist").Budget.DailyCostUsd = 40; // deliberately large
|
||||||
|
Find("marketing-lead").Budget.DailyCostUsd = 6;
|
||||||
|
|
||||||
|
var preview = Org.PreviewSetting("marketing", UnitSetting.DailyCostUsd, "10");
|
||||||
|
|
||||||
|
preview.Count.ShouldBe(4, "the unit and its sub-units");
|
||||||
|
var specialist = preview.Single(c => c.AgentId == "specialist");
|
||||||
|
specialist.Outlier.ShouldBeTrue();
|
||||||
|
specialist.SelectedByDefault.ShouldBeFalse();
|
||||||
|
preview.Where(c => c.AgentId != "specialist").ShouldAllBe(c => c.SelectedByDefault && c.Proposed == "10.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_distributed_total_keeps_the_proportions()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
Find("ads").Budget.DailyCostUsd = 10;
|
||||||
|
Find("listing").Budget.DailyCostUsd = 10;
|
||||||
|
Find("specialist").Budget.DailyCostUsd = 20;
|
||||||
|
Find("marketing-lead").Budget.DailyCostUsd = 0;
|
||||||
|
|
||||||
|
var preview = Org.PreviewSetting("amazon", UnitSetting.DailyCostUsd, "80", UnitSettingMode.Distribute);
|
||||||
|
|
||||||
|
preview.Single(c => c.AgentId == "specialist").Proposed.ShouldBe("40.00");
|
||||||
|
preview.Single(c => c.AgentId == "ads").Proposed.ShouldBe("20.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Applying_writes_only_the_chosen_agents_and_remembers_the_default()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
Find("specialist").Budget.DailyCostUsd = 40;
|
||||||
|
|
||||||
|
var result = Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
|
||||||
|
new Dictionary<string, string> { ["ads"] = "10", ["listing"] = "10" }, "budget round", defaultValue: "10");
|
||||||
|
|
||||||
|
result.Ok.ShouldBeTrue(result.Message);
|
||||||
|
Find("ads").Budget.DailyCostUsd.ShouldBe(10);
|
||||||
|
Find("specialist").Budget.DailyCostUsd.ShouldBe(40, "not chosen, not touched");
|
||||||
|
Org.Current().Unit("amazon")!.Defaults.DailyCostUsd.ShouldBe(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_agent_outside_the_unit_cannot_be_written_through_it()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
|
||||||
|
Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
|
||||||
|
new Dictionary<string, string> { ["bookkeeper"] = "1" }, "sneaky").Ok.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_new_agent_placed_in_a_unit_starts_with_its_defaults_but_a_moved_one_keeps_its_values()
|
||||||
|
{
|
||||||
|
BuildMarketing();
|
||||||
|
Org.ApplySetting("amazon", UnitSetting.DailyCostUsd, new Dictionary<string, string>(), "defaults", defaultValue: "7");
|
||||||
|
Find("bookkeeper").Budget.DailyCostUsd = 30;
|
||||||
|
|
||||||
|
Org.MoveAgent("bookkeeper", "amazon", "moved").Ok.ShouldBeTrue();
|
||||||
|
Find("bookkeeper").Budget.DailyCostUsd.ShouldBe(30, "a move does not overwrite tuned values");
|
||||||
|
|
||||||
|
var created = _test.Host.AgentAdmin.Add(new NewAgent("newbie", "Newbie", "test/model", UnitId: "amazon"));
|
||||||
|
created.Ok.ShouldBeTrue(created.Message);
|
||||||
|
Find("newbie").Budget.DailyCostUsd.ShouldBe(7);
|
||||||
|
Find("newbie").UnitId.ShouldBe("amazon");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Organisation;
|
||||||
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
using ClawdDotNet.Core.Tools;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Organisation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: The address book is enforced by the engine — not merely described in a prompt that
|
||||||
|
/// a model may or may not follow. And every agent is told where it stands.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddressBookEnforcementTests
|
||||||
|
{
|
||||||
|
/// <summary>marketing (lead: marketing-lead) › amazon (lead: amazon-lead, ads); accounting: bookkeeper.</summary>
|
||||||
|
private static (EngineFixture Fixture, List<UnitConfig> Units) Shop()
|
||||||
|
{
|
||||||
|
var fixture = new EngineFixture();
|
||||||
|
var units = new List<UnitConfig>
|
||||||
|
{
|
||||||
|
new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
|
||||||
|
new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead", Goal = "Sell on Amazon." },
|
||||||
|
new() { Id = "accounting", Name = "Accounting" }
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (id, unit) in new[]
|
||||||
|
{
|
||||||
|
("marketing-lead", "marketing"), ("amazon-lead", "amazon"), ("ads", "amazon"), ("bookkeeper", "accounting")
|
||||||
|
})
|
||||||
|
fixture.AddAgent(id).UnitId = unit;
|
||||||
|
|
||||||
|
fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents);
|
||||||
|
return (fixture, units);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ messages and spawns ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_message_outside_the_address_book_is_refused_with_the_way_that_is_open()
|
||||||
|
{
|
||||||
|
var (shop, _) = Shop();
|
||||||
|
|
||||||
|
var result = await shop.Engine.SendMessageAsync("ads", "bookkeeper", "Kannst du zahlen?", "inst", null, default);
|
||||||
|
|
||||||
|
result.Delivered.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull().ShouldContain("not in your address book");
|
||||||
|
result.Error.ShouldContain("amazon-lead", Case.Sensitive, "the way that is open: the own lead");
|
||||||
|
shop.Client.CallCount.ShouldBe(0, "refused before any model call");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_message_to_the_own_lead_goes_through()
|
||||||
|
{
|
||||||
|
var (shop, _) = Shop();
|
||||||
|
shop.Client.RespondsWithText("Verstanden.");
|
||||||
|
|
||||||
|
var result = await shop.Engine.SendMessageAsync("ads", "amazon-lead", "Frage", "inst", null, default);
|
||||||
|
|
||||||
|
result.Delivered.ShouldBeTrue(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_spawn_is_held_to_the_same_address_book()
|
||||||
|
{
|
||||||
|
var (shop, _) = Shop();
|
||||||
|
|
||||||
|
var result = await shop.Engine.SpawnAgentAsync("ads", "bookkeeper", "Buche das", "inst", null, default);
|
||||||
|
|
||||||
|
result.Started.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull().ShouldContain("not in your address book");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_agent_list_shows_only_the_address_book()
|
||||||
|
{
|
||||||
|
var (shop, _) = Shop();
|
||||||
|
|
||||||
|
shop.Engine.ListAgents("ads").Select(a => a.AgentId).ShouldBe(["amazon-lead"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_units_the_agent_list_is_unchanged()
|
||||||
|
{
|
||||||
|
var fixture = new EngineFixture();
|
||||||
|
fixture.AddAgent("a");
|
||||||
|
fixture.AddAgent("b");
|
||||||
|
fixture.AddAgent("c");
|
||||||
|
|
||||||
|
fixture.Engine.ListAgents("a").Select(x => x.AgentId).ShouldBe(["b", "c"], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ delegation depth ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_delegation_limit_follows_the_depth_of_the_tree()
|
||||||
|
{
|
||||||
|
// Five levels deep: with the old fixed limit of 3, the bottom could not be reached.
|
||||||
|
var fixture = new EngineFixture();
|
||||||
|
var units = new List<UnitConfig>();
|
||||||
|
string? parent = null;
|
||||||
|
for (var level = 1; level <= 5; level++)
|
||||||
|
{
|
||||||
|
units.Add(new UnitConfig { Id = $"u{level}", Name = $"U{level}", ParentId = parent, LeaderAgentId = $"lead{level}" });
|
||||||
|
fixture.AddAgent($"lead{level}").UnitId = $"u{level}";
|
||||||
|
parent = $"u{level}";
|
||||||
|
}
|
||||||
|
fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents);
|
||||||
|
fixture.Client.RespondsWithText("ok");
|
||||||
|
|
||||||
|
// lead1 → lead2 → lead3 → lead4 → lead5: the fourth hand-over.
|
||||||
|
var chain = DelegationChain.Root("lead1").Append("lead2").Append("lead3").Append("lead4");
|
||||||
|
var result = await fixture.Engine.SendMessageAsync("lead4", "lead5", "weiter", "inst", chain, default);
|
||||||
|
|
||||||
|
result.Delivered.ShouldBeTrue(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ the organisation section ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Every_run_tells_the_agent_its_unit_and_its_lead()
|
||||||
|
{
|
||||||
|
var (shop, _) = Shop();
|
||||||
|
shop.Client.RespondsWithText("ok");
|
||||||
|
|
||||||
|
await shop.Engine.RunAsync(shop.Agents.First(a => a.AgentId == "ads"), "Los", "inst", default);
|
||||||
|
|
||||||
|
var system = shop.Client.ReceivedRequests[0].Messages[0].Content!;
|
||||||
|
system.ShouldContain(OrgBriefing.Heading);
|
||||||
|
system.ShouldContain("\"Amazon\" (Marketing / Amazon)");
|
||||||
|
system.ShouldContain("Your lead: amazon-lead");
|
||||||
|
system.ShouldContain("Goal of your unit: Sell on Amazon.");
|
||||||
|
system.ShouldNotContain("bookkeeper", Case.Sensitive, "not in the address book — not in the prompt either");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_move_reaches_a_running_chat_context()
|
||||||
|
{
|
||||||
|
var (shop, units) = Shop();
|
||||||
|
var ads = shop.Agents.First(a => a.AgentId == "ads");
|
||||||
|
shop.Client.RespondsWithText("ok").RespondsWithText("ok");
|
||||||
|
|
||||||
|
await shop.Engine.ChatAsync(ads, "Hallo", "inst", default);
|
||||||
|
shop.Client.ReceivedRequests[0].Messages[0].Content!.ShouldContain("Your lead: amazon-lead");
|
||||||
|
|
||||||
|
// Moved to accounting, which has no lead: the next run knows it.
|
||||||
|
ads.UnitId = "accounting";
|
||||||
|
ads.UnitSinceUtc = DateTime.UtcNow;
|
||||||
|
await shop.Engine.ChatAsync(ads, "Und jetzt?", "inst", default);
|
||||||
|
|
||||||
|
var system = shop.Client.ReceivedRequests[1].Messages[0].Content!;
|
||||||
|
system.ShouldContain("\"Accounting\"");
|
||||||
|
system.ShouldContain("this is recent");
|
||||||
|
system.ShouldContain("There is no lead agent above you");
|
||||||
|
system.ShouldStartWith("Du bist ads.", Case.Sensitive, "the rest of the prompt is untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Without_units_the_prompt_has_no_organisation_section()
|
||||||
|
{
|
||||||
|
var fixture = new EngineFixture();
|
||||||
|
var agent = fixture.AddAgent("solo");
|
||||||
|
fixture.Client.RespondsWithText("ok");
|
||||||
|
|
||||||
|
await fixture.Engine.RunAsync(agent, "Los", "inst", default);
|
||||||
|
|
||||||
|
fixture.Client.ReceivedRequests[0].Messages[0].Content!.ShouldNotContain(OrgBriefing.Heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_section_is_replaced_not_stacked()
|
||||||
|
{
|
||||||
|
var once = OrgBriefing.WithSection("Base.", "# Organisation\nA");
|
||||||
|
var twice = OrgBriefing.WithSection(once, "# Organisation\nB");
|
||||||
|
|
||||||
|
twice.ShouldBe("Base.\n\n# Organisation\nB");
|
||||||
|
OrgBriefing.WithSection(twice, null).ShouldBe("Base.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Organisation;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Organisation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: The organisation tree and the address book it cuts. The example is the online
|
||||||
|
/// shop from the concept, trimmed:
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// (top level) ceo
|
||||||
|
/// ├── purchasing lead: purchasing-lead
|
||||||
|
/// │ ├── suppliers lead: suppliers-lead · negotiator
|
||||||
|
/// │ └── quality (no lead) · sampler
|
||||||
|
/// ├── marketing lead: marketing-lead
|
||||||
|
/// │ └── marketplaces lead: marketplaces-lead
|
||||||
|
/// │ └── amazon lead: amazon-lead · amazon-ads, amazon-listing
|
||||||
|
/// └── accounting lead: accounting-lead · bookkeeper
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrgChartTests
|
||||||
|
{
|
||||||
|
private static UnitConfig Unit(string id, string? parent = null, string? lead = null, List<string>? ceiling = null)
|
||||||
|
=> new() { Id = id, Name = char.ToUpperInvariant(id[0]) + id[1..], ParentId = parent, LeaderAgentId = lead, ToolCeiling = ceiling };
|
||||||
|
|
||||||
|
private static AgentConfig Agent(string id, string? unit = null, params string[] tools)
|
||||||
|
=> new() { AgentId = id, DisplayName = id, UnitId = unit, Tools = tools.ToDictionary(t => t, _ => new Dictionary<string, object?>()) };
|
||||||
|
|
||||||
|
private static OrgChart Shop() => new(
|
||||||
|
[
|
||||||
|
Unit("purchasing", lead: "purchasing-lead"),
|
||||||
|
Unit("suppliers", "purchasing", "suppliers-lead"),
|
||||||
|
Unit("quality", "purchasing"),
|
||||||
|
Unit("marketing", lead: "marketing-lead"),
|
||||||
|
Unit("marketplaces", "marketing", "marketplaces-lead"),
|
||||||
|
Unit("amazon", "marketplaces", "amazon-lead"),
|
||||||
|
Unit("accounting", lead: "accounting-lead")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Agent("ceo"),
|
||||||
|
Agent("purchasing-lead", "purchasing"),
|
||||||
|
Agent("suppliers-lead", "suppliers"),
|
||||||
|
Agent("negotiator", "suppliers"),
|
||||||
|
Agent("sampler", "quality"),
|
||||||
|
Agent("marketing-lead", "marketing"),
|
||||||
|
Agent("marketplaces-lead", "marketplaces"),
|
||||||
|
Agent("amazon-lead", "amazon"),
|
||||||
|
Agent("amazon-ads", "amazon"),
|
||||||
|
Agent("amazon-listing", "amazon"),
|
||||||
|
Agent("accounting-lead", "accounting"),
|
||||||
|
Agent("bookkeeper", "accounting")
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ═══════════════════ the tree ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Paths_and_depths_follow_the_tree()
|
||||||
|
{
|
||||||
|
var org = Shop();
|
||||||
|
|
||||||
|
org.PathOf("amazon").ShouldBe("Marketing / Marketplaces / Amazon");
|
||||||
|
org.Depth("marketing").ShouldBe(1);
|
||||||
|
org.Depth("amazon").ShouldBe(3);
|
||||||
|
org.TreeDepth.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Delegation_may_reach_from_the_top_to_the_bottom_and_one_step_further()
|
||||||
|
{
|
||||||
|
Shop().DelegationDepth(configuredFloor: 3).ShouldBe(4, "before #683 it was a fixed 3");
|
||||||
|
Shop().DelegationDepth(configuredFloor: 6).ShouldBe(6, "the configured value stays a floor");
|
||||||
|
new OrgChart([], [Agent("a")]).DelegationDepth(3).ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ leads ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_member_answers_to_the_lead_of_its_unit()
|
||||||
|
=> Shop().EffectiveLeaderOf("amazon-ads")!.AgentId.ShouldBe("amazon-lead");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_lead_answers_to_the_lead_above()
|
||||||
|
=> Shop().EffectiveLeaderOf("amazon-lead")!.AgentId.ShouldBe("marketplaces-lead");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void In_a_unit_without_lead_the_nearest_lead_above_takes_over()
|
||||||
|
=> Shop().EffectiveLeaderOf("sampler")!.AgentId.ShouldBe("purchasing-lead");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Above_a_top_level_lead_there_is_only_the_human()
|
||||||
|
=> Shop().EffectiveLeaderOf("marketing-lead").ShouldBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_lead_who_is_not_a_member_does_not_count()
|
||||||
|
{
|
||||||
|
var org = new OrgChart([Unit("team", lead: "outsider")], [Agent("outsider"), Agent("member", "team")]);
|
||||||
|
|
||||||
|
org.LeaderOf("team").ShouldBeNull();
|
||||||
|
org.Problems().ShouldContain(p => p.Contains("not a member"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ the address book ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_member_sees_its_unit_and_its_lead_and_nothing_else()
|
||||||
|
{
|
||||||
|
Shop().AddressBook("amazon-ads").ShouldBe(["amazon-lead", "amazon-listing"], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_logistician_does_not_learn_that_an_ads_agent_exists()
|
||||||
|
{
|
||||||
|
// The example from the item: with a flat list, everyone saw everyone.
|
||||||
|
Shop().CanAddress("bookkeeper", "amazon-ads").ShouldBeFalse();
|
||||||
|
Shop().CanAddress("amazon-ads", "bookkeeper").ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_lead_sees_its_team_its_sub_leads_its_sibling_leads_and_its_own_lead()
|
||||||
|
{
|
||||||
|
Shop().AddressBook("marketplaces-lead").ShouldBe(
|
||||||
|
["amazon-lead", "marketing-lead"], ignoreOrder: true);
|
||||||
|
|
||||||
|
Shop().AddressBook("purchasing-lead").ShouldBe(
|
||||||
|
// sub-lead; the lead-less quality team directly; sibling leads; the top level.
|
||||||
|
["suppliers-lead", "sampler", "marketing-lead", "accounting-lead", "ceo"], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Members_of_a_lead_less_unit_and_the_lead_above_see_each_other()
|
||||||
|
{
|
||||||
|
var org = Shop();
|
||||||
|
|
||||||
|
org.CanAddress("sampler", "purchasing-lead").ShouldBeTrue();
|
||||||
|
org.CanAddress("purchasing-lead", "sampler").ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_top_level_and_the_top_level_leads_see_each_other()
|
||||||
|
{
|
||||||
|
var org = Shop();
|
||||||
|
|
||||||
|
org.AddressBook("ceo").ShouldBe(["purchasing-lead", "marketing-lead", "accounting-lead"], ignoreOrder: true);
|
||||||
|
org.CanAddress("marketing-lead", "ceo").ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_line_can_be_walked_both_ways()
|
||||||
|
{
|
||||||
|
// Whoever I can reach as my lead can reach me: escalation and delegation use the
|
||||||
|
// same line.
|
||||||
|
var org = Shop();
|
||||||
|
foreach (var agent in org.Units.SelectMany(u => org.MembersOf(u.Id)).Concat(org.MembersOf(null)))
|
||||||
|
{
|
||||||
|
foreach (var other in org.AddressBook(agent.AgentId))
|
||||||
|
org.CanAddress(other, agent.AgentId).ShouldBeTrue($"{agent.AgentId} → {other} but not back");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_units_everyone_reaches_everyone_as_before()
|
||||||
|
{
|
||||||
|
var org = new OrgChart([], [Agent("a"), Agent("b"), Agent("c")]);
|
||||||
|
|
||||||
|
org.HasUnits.ShouldBeFalse();
|
||||||
|
org.AddressBook("a").ShouldBe(["b", "c"], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_unknown_agent_reaches_nobody()
|
||||||
|
=> Shop().AddressBook("ghost").ShouldBeEmpty();
|
||||||
|
|
||||||
|
// ═══════════════════ tool ceiling ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_ceiling_applies_along_the_whole_path_down()
|
||||||
|
{
|
||||||
|
var org = new OrgChart(
|
||||||
|
[
|
||||||
|
Unit("marketing", ceiling: ["WebFetch", "Taskboard", "AgentComm"]),
|
||||||
|
Unit("amazon", "marketing", ceiling: ["WebFetch", "Taskboard", "FileRW"])
|
||||||
|
],
|
||||||
|
[Agent("ads", "amazon")]);
|
||||||
|
|
||||||
|
org.ToolCeiling("amazon")!.ShouldBe(["WebFetch", "Taskboard"], ignoreOrder: true,
|
||||||
|
"FileRW is not allowed further up, AgentComm not here");
|
||||||
|
org.ToolCeiling("marketing")!.Count.ShouldBe(3);
|
||||||
|
org.ToolCeiling(null).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tools_beyond_the_ceiling_are_named_as_a_problem()
|
||||||
|
{
|
||||||
|
var org = new OrgChart(
|
||||||
|
[Unit("marketing", ceiling: ["WebFetch"])],
|
||||||
|
[Agent("ads", "marketing", "WebFetch", "Database")]);
|
||||||
|
|
||||||
|
org.Problems().ShouldContain(p => p.Contains("ads") && p.Contains("Database"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ broken structures ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_cycle_is_named_and_does_not_hang()
|
||||||
|
{
|
||||||
|
var org = new OrgChart([Unit("a", "b"), Unit("b", "a")], [Agent("x", "a")]);
|
||||||
|
|
||||||
|
org.Problems().ShouldContain(p => p.Contains("cycle"));
|
||||||
|
org.Ancestors("a").Count.ShouldBeLessThan(3);
|
||||||
|
org.AddressBook("x").ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_references_are_named()
|
||||||
|
{
|
||||||
|
var org = new OrgChart([Unit("team", "nowhere")], [Agent("x", "missing")]);
|
||||||
|
|
||||||
|
org.Problems().ShouldContain(p => p.Contains("nowhere"));
|
||||||
|
org.Problems().ShouldContain(p => p.Contains("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_sound_structure_has_no_problems()
|
||||||
|
=> Shop().Problems().ShouldBeEmpty();
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Organisation;
|
||||||
|
using ClawdDotNet.Core.Storage;
|
||||||
|
using ClawdDotNet.Core.Tasks;
|
||||||
|
using ClawdDotNet.Core.Tools;
|
||||||
|
using ClawdDotNet.Tools.Taskboard;
|
||||||
|
using ClawdDotNet.Tools.Tests.FileRW;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Tools.Tests.Taskboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: A task for another agent is a way of reaching it. The address book that holds for
|
||||||
|
/// messages holds here too — otherwise it could be walked around with task_create.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AssignmentReachTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory;
|
||||||
|
private readonly SqliteStorage _storage;
|
||||||
|
private readonly SqliteTaskRepository _repo;
|
||||||
|
private readonly TaskboardTool _tool = new();
|
||||||
|
|
||||||
|
private readonly OrgChart _org = new(
|
||||||
|
[
|
||||||
|
new UnitConfig { Id = "amazon", Name = "Amazon", LeaderAgentId = "amazon-lead" },
|
||||||
|
new UnitConfig { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
|
||||||
|
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
|
||||||
|
new AgentConfig { AgentId = "accounting-lead", UnitId = "accounting" },
|
||||||
|
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
public AssignmentReachTests()
|
||||||
|
{
|
||||||
|
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||||
|
_repo = new SqliteTaskRepository(_storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||||
|
try { Directory.Delete(_directory, recursive: true); }
|
||||||
|
catch { /* cleanup is secondary */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<ToolResult> Run(string agentId, string json)
|
||||||
|
{
|
||||||
|
var context = new AgentToolContext(
|
||||||
|
agentId, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
|
||||||
|
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo)
|
||||||
|
{
|
||||||
|
Organisation = _org
|
||||||
|
};
|
||||||
|
return _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, context, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_task_for_an_agent_outside_the_address_book_is_refused()
|
||||||
|
{
|
||||||
|
var result = await Run("ads", """{"action":"task_create","title":"Rechnung buchen","assignee":"@bookkeeper"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("not in your address book");
|
||||||
|
result.ErrorMessage.ShouldContain("amazon-lead");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_fresh_run_of_an_agent_outside_is_refused_too()
|
||||||
|
{
|
||||||
|
var result = await Run("ads", """{"action":"task_create","title":"Rechnung buchen","assignee":"@new:bookkeeper"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_task_for_the_own_lead_or_the_human_goes_through()
|
||||||
|
{
|
||||||
|
(await Run("ads", """{"action":"task_create","title":"Frage an die Leitung","assignee":"@amazon-lead"}""")).Success.ShouldBeTrue();
|
||||||
|
(await Run("ads", """{"action":"task_create","title":"Frage an den Menschen","assignee":"@human"}""")).Success.ShouldBeTrue();
|
||||||
|
(await Run("ads", """{"action":"task_create","title":"Eigene Aufgabe","assignee":"@ads"}""")).Success.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reassigning_is_held_to_the_same_rule()
|
||||||
|
{
|
||||||
|
var created = await Run("ads", """{"action":"task_create","title":"Eigene Aufgabe","assignee":"@ads"}""");
|
||||||
|
var id = created.Content!.Split(' ', ':', '\n').First(w => w.StartsWith("t-"));
|
||||||
|
|
||||||
|
var result = await Run("ads", $$"""{"action":"task_update","id":"{{id}}","assignee":"@bookkeeper"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user