Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
920d3fed64 | ||
|
|
5b8656d969 | ||
|
|
f8d0ce94fe | ||
|
|
f34756f4bb | ||
|
|
e4ecf2d5ca | ||
|
|
68a432fec6 | ||
|
|
626fc8d593 | ||
|
|
d014a20c38 | ||
|
|
dc11961805 |
@@ -114,7 +114,8 @@ public sealed class AppHost : IAsyncDisposable
|
||||
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
|
||||
|
||||
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
|
||||
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents);
|
||||
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents,
|
||||
organisation: () => Core.Organisation.OrgChart.From(Instance));
|
||||
|
||||
public TelegramClientManager? TelegramClient { get; private init; }
|
||||
public OpenRouterStatusService? Status { get; private init; }
|
||||
@@ -214,6 +215,9 @@ public sealed class AppHost : IAsyncDisposable
|
||||
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
|
||||
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>
|
||||
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
|
||||
|
||||
@@ -565,6 +569,12 @@ public sealed class AppHost : IAsyncDisposable
|
||||
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();
|
||||
|
||||
var (scanner, staging, board) = BuildTaskboard(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
@@ -9,7 +10,8 @@ public sealed record NewAgent(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
string Model,
|
||||
string Description = "");
|
||||
string Description = "",
|
||||
string? UnitId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
|
||||
@@ -86,6 +88,10 @@ public sealed class AgentAdministration(AppHost host)
|
||||
if (Find(agentId) is not null)
|
||||
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;
|
||||
|
||||
try
|
||||
@@ -111,6 +117,14 @@ public sealed class AgentAdministration(AppHost host)
|
||||
config.Model = request.Model;
|
||||
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.Instance.Agents.Add(config);
|
||||
@@ -181,6 +195,10 @@ public sealed class AgentAdministration(AppHost host)
|
||||
|
||||
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))
|
||||
agent.Tools[toolName] = new Dictionary<string, object?>();
|
||||
}
|
||||
@@ -278,6 +296,10 @@ public sealed class AgentAdministration(AppHost host)
|
||||
if (Find(agentId) is not { } agent)
|
||||
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
|
||||
// Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und
|
||||
// "verboten" - und damit die Rechte des Agenten.
|
||||
@@ -309,6 +331,22 @@ public sealed class AgentAdministration(AppHost host)
|
||||
/// </summary>
|
||||
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)
|
||||
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
|
||||
/// der nächste Ladevorgang ohnehin.</para>
|
||||
/// </summary>
|
||||
private void PersistAgent(AgentConfig agent)
|
||||
internal void PersistAgent(AgentConfig agent)
|
||||
{
|
||||
// Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Activity;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
@@ -40,10 +41,33 @@ public sealed record TeamMember(
|
||||
string? LastRunStatus,
|
||||
DateTime? LastRunAtUtc,
|
||||
string? LastRunResult,
|
||||
long TypicalRunMs);
|
||||
long TypicalRunMs,
|
||||
string? UnitId = null,
|
||||
bool IsLead = false);
|
||||
|
||||
/// <summary>The whole picture at one moment: every agent, and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc);
|
||||
/// <summary>
|
||||
/// One unit in the team overview (#711, stage 1), with what happens inside it rolled up —
|
||||
/// its own members and those of every unit below. A collapsed container shows this, so a
|
||||
/// problem three levels down is still visible from the top.
|
||||
/// </summary>
|
||||
public sealed record TeamUnit(
|
||||
string Id,
|
||||
string Name,
|
||||
string? ParentId,
|
||||
string Path,
|
||||
string? LeaderAgentId,
|
||||
string? Goal,
|
||||
int Agents,
|
||||
IReadOnlyDictionary<TeamState, int> States,
|
||||
int PendingApprovals,
|
||||
TeamState Worst);
|
||||
|
||||
/// <summary>The whole picture at one moment: every agent and unit, and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshot(
|
||||
IReadOnlyList<TeamMember> Members,
|
||||
IReadOnlyList<AgentLink> RecentLinks,
|
||||
DateTime AtUtc,
|
||||
IReadOnlyList<TeamUnit>? Units = null);
|
||||
|
||||
/// <summary>
|
||||
/// #711: The team overview — who is working, who reached whom, where something is stuck.
|
||||
@@ -63,7 +87,8 @@ public sealed class TeamOverview(
|
||||
IAuditRepository? audit,
|
||||
IUsageRepository? usage,
|
||||
Func<IReadOnlyList<AgentConfig>> agents,
|
||||
TimeProvider? clock = null)
|
||||
TimeProvider? clock = null,
|
||||
Func<OrgChart>? organisation = null)
|
||||
{
|
||||
/// <summary>How far back the last receipt of each agent is looked for.</summary>
|
||||
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
|
||||
@@ -106,6 +131,8 @@ public sealed class TeamOverview(
|
||||
}
|
||||
}
|
||||
|
||||
var org = organisation?.Invoke() ?? new OrgChart([], configured);
|
||||
|
||||
var members = new List<TeamMember>();
|
||||
foreach (var agent in configured)
|
||||
{
|
||||
@@ -120,10 +147,54 @@ public sealed class TeamOverview(
|
||||
var state = Classify(runs, approvals, last?.Status, typical, now);
|
||||
members.Add(new TeamMember(
|
||||
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
|
||||
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds));
|
||||
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds,
|
||||
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null));
|
||||
}
|
||||
|
||||
return new TeamSnapshot(members, links, now);
|
||||
return new TeamSnapshot(members, links, now, RollUp(org, members));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How much a state needs someone — the higher, the sooner. A container takes the most
|
||||
/// urgent state of everything inside it, so "somewhere in here" points at the right thing.
|
||||
/// </summary>
|
||||
public static int Urgency(TeamState state) => state switch
|
||||
{
|
||||
TeamState.Stuck => 5,
|
||||
TeamState.Problem => 4,
|
||||
TeamState.WaitingForApproval => 3,
|
||||
TeamState.BudgetExhausted => 2,
|
||||
TeamState.Working => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Every unit with the states of all agents in it and below. Pure, like
|
||||
/// <see cref="Classify"/>: the page draws the numbers, it does not add them up.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<TeamUnit> RollUp(OrgChart org, IReadOnlyList<TeamMember> members)
|
||||
{
|
||||
var result = new List<TeamUnit>();
|
||||
foreach (var unit in org.Units.OrderBy(u => org.PathOf(u.Id), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
// Everyone whose unit is this one or lies below it. Ancestors() stops at a
|
||||
// cycle, so a broken tree cannot hang the overview.
|
||||
var inside = members
|
||||
.Where(m => m.UnitId is { } id
|
||||
&& (string.Equals(id, unit.Id, StringComparison.OrdinalIgnoreCase)
|
||||
|| org.Ancestors(id).Any(a => string.Equals(a.Id, unit.Id, StringComparison.OrdinalIgnoreCase))))
|
||||
.ToList();
|
||||
|
||||
var states = inside
|
||||
.GroupBy(m => m.State)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
var worst = inside.Select(m => m.State).DefaultIfEmpty(TeamState.Idle).MaxBy(Urgency);
|
||||
|
||||
result.Add(new TeamUnit(
|
||||
unit.Id, unit.Name, unit.ParentId, org.PathOf(unit.Id), org.LeaderOf(unit.Id)?.AgentId, unit.Goal,
|
||||
inside.Count, states, inside.Sum(m => m.PendingApprovals), worst));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -157,7 +157,10 @@ public sealed class BackupService
|
||||
var connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = sourcePath,
|
||||
Mode = SqliteOpenMode.ReadOnly
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
// Eine einmalige Kopie: Aus dem Pool heraus hielte die Verbindung die Datei
|
||||
// danach offen, und unter Windows ließe sie sich nicht mehr ersetzen oder löschen.
|
||||
Pooling = false
|
||||
}.ToString();
|
||||
|
||||
using var conn = new SqliteConnection(connectionString);
|
||||
|
||||
@@ -10,6 +10,24 @@ public sealed class AgentConfig
|
||||
[JsonPropertyName("displayName")]
|
||||
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")]
|
||||
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
|
||||
|
||||
|
||||
@@ -172,6 +172,13 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("agents")]
|
||||
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")]
|
||||
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; }
|
||||
}
|
||||
@@ -44,6 +44,21 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
/// <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>
|
||||
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
|
||||
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
|
||||
@@ -226,7 +241,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// #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))
|
||||
messages.Add(ChatMessage.System(systemPrompt));
|
||||
|
||||
@@ -556,11 +572,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
messages = new List<ChatMessage>();
|
||||
// #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren
|
||||
// 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))
|
||||
messages.Add(ChatMessage.System(systemPrompt));
|
||||
_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
|
||||
@@ -1052,19 +1079,41 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
$"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " +
|
||||
"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 " +
|
||||
$"{MaxDelegationDepth} Weitergaben erreicht. Erledige den Rest selbst " +
|
||||
$"{maxDepth} Weitergaben erreicht. Erledige den Rest selbst " +
|
||||
"oder gib die Aufgabe an den Auftraggeber zurück.";
|
||||
|
||||
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)
|
||||
{
|
||||
var configs = _agentConfigProvider?.Invoke() ?? [];
|
||||
// #683: Only the agent's address book — the cut of the organisation.
|
||||
var reachable = CurrentOrganisation().AddressBook(callerAgentId);
|
||||
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)))
|
||||
.ToList();
|
||||
}
|
||||
@@ -1097,6 +1146,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
if (targetConfig is null)
|
||||
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)
|
||||
return new AgentMessageResult(false, null, refused);
|
||||
|
||||
@@ -1149,6 +1201,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
// 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.
|
||||
if (CheckAddressBook(fromAgentId, targetAgentId) is { } outOfReach)
|
||||
return new AgentSpawnResult(false, targetAgentId, null, outOfReach);
|
||||
|
||||
if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused)
|
||||
return new AgentSpawnResult(false, targetAgentId, null, refused);
|
||||
|
||||
@@ -1606,7 +1661,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
this,
|
||||
_memoryRepository,
|
||||
_taskRepository,
|
||||
chain ?? DelegationChain.Root(agentConfig.AgentId));
|
||||
chain ?? DelegationChain.Root(agentConfig.AgentId))
|
||||
{
|
||||
Organisation = CurrentOrganisation()
|
||||
};
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
@@ -19,6 +20,9 @@ public sealed class SqliteStorage
|
||||
private readonly string _connectionString;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
|
||||
/// <summary>Every database opened in this process: full path → connection string.</summary>
|
||||
private static readonly ConcurrentDictionary<string, string> OpenedDatabases = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string DatabasePath { get; }
|
||||
|
||||
public SqliteStorage(string databasePath)
|
||||
@@ -37,6 +41,7 @@ public sealed class SqliteStorage
|
||||
Pooling = true,
|
||||
DefaultTimeout = 30
|
||||
}.ToString();
|
||||
OpenedDatabases[Path.GetFullPath(databasePath)] = _connectionString;
|
||||
|
||||
Initialize();
|
||||
|
||||
@@ -51,6 +56,33 @@ public sealed class SqliteStorage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the pooled connections of the databases at <paramref name="path"/> — a
|
||||
/// database file, or a directory and everything below it — so the files can be deleted
|
||||
/// or replaced.
|
||||
///
|
||||
/// <para>Not <see cref="SqliteConnection.ClearAllPools"/>: that is process-wide and also
|
||||
/// takes the connections of every other database in use. The tests used it to clean up
|
||||
/// their temporary databases, and a test running in parallel then lost its connection
|
||||
/// while opening it — an ObjectDisposedException in <c>ApplyPragmas</c>, one in every
|
||||
/// few full runs, always in a different test.</para>
|
||||
/// </summary>
|
||||
public static void ReleasePooledConnections(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
var below = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
|
||||
foreach (var (database, connectionString) in OpenedDatabases)
|
||||
{
|
||||
if (!string.Equals(database, full, StringComparison.OrdinalIgnoreCase)
|
||||
&& !database.StartsWith(below, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
using var connection = new SqliteConnection(connectionString);
|
||||
SqliteConnection.ClearPool(connection);
|
||||
}
|
||||
}
|
||||
|
||||
public SqliteConnection OpenConnection()
|
||||
{
|
||||
var connection = new SqliteConnection(_connectionString);
|
||||
|
||||
@@ -34,4 +34,11 @@ public sealed record AgentToolContext(
|
||||
/// Ohne Freigabestelle bleibt es <c>false</c>, und das Werkzeug prueft selbst.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1791,7 +1791,126 @@ td.klein {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.team-eintrag {
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.team-flaeche { flex-direction: column; }
|
||||
.team-seitenleiste { width: auto; }
|
||||
}
|
||||
|
||||
/* ─── 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; }
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { renderUsageTimeline } from "./views/usage-timeline.js";
|
||||
import { renderRunDetail } from "./views/run-detail.js";
|
||||
import { renderPromptHistory } from "./views/prompt-history.js";
|
||||
import { renderTeam, leaveTeam } from "./views/team.js";
|
||||
import { renderOrganisation } from "./views/organisation.js";
|
||||
|
||||
// ─── 1. Routen registrieren ───
|
||||
|
||||
@@ -53,6 +54,7 @@ registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten();
|
||||
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
|
||||
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
|
||||
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
|
||||
registriereRoute("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); });
|
||||
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
|
||||
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
|
||||
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
|
||||
|
||||
@@ -68,8 +68,67 @@
|
||||
"nav.agents": "Agenten",
|
||||
"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.subtitle": "Wer arbeitet, wer hat wen in den letzten Sekunden erreicht und wo klemmt es. Einheiten zeigen, was in ihnen passiert; ein Klick öffnet sie oder zeigt Einzelheiten.",
|
||||
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
|
||||
"team.empty": "Diese Instanz hat keine Agenten.",
|
||||
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
|
||||
@@ -89,6 +148,17 @@
|
||||
"team.openApprovals": "Freigaben öffnen",
|
||||
"team.openChat": "Chat öffnen",
|
||||
"team.openRuns": "Läufe anzeigen",
|
||||
"team.unitAgentsOne": "1 Agent",
|
||||
"team.unitAgents": "{n} Agenten",
|
||||
"team.legendLead": "Leitung der Einheit",
|
||||
"team.legendUnit": "Einheit, Klick öffnet sie",
|
||||
"team.leads": "Leitet",
|
||||
"team.memberOf": "Mitglied von",
|
||||
"team.members": "Mitglieder",
|
||||
"team.subUnits": "Untereinheiten",
|
||||
"team.collapse": "Einheit zuklappen",
|
||||
"team.expand": "Einheit aufklappen",
|
||||
"team.openOrganisation": "Organisation bearbeiten",
|
||||
"nav.services": "Dienste",
|
||||
"nav.group.analytics": "Analyse",
|
||||
"nav.usage": "Tokennutzung",
|
||||
|
||||
@@ -68,8 +68,67 @@
|
||||
"nav.agents": "Agents",
|
||||
"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.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Units show what happens inside them; click to open them or to see details.",
|
||||
"team.noInstance": "No instance selected. Choose an active instance in the header.",
|
||||
"team.empty": "No agents in this instance.",
|
||||
"team.legendLink": "reached (fades after 30 s)",
|
||||
@@ -89,6 +148,17 @@
|
||||
"team.openApprovals": "Open approvals",
|
||||
"team.openChat": "Open chat",
|
||||
"team.openRuns": "Show runs",
|
||||
"team.unitAgentsOne": "1 agent",
|
||||
"team.unitAgents": "{n} agents",
|
||||
"team.legendLead": "lead of the unit",
|
||||
"team.legendUnit": "unit, click to open",
|
||||
"team.leads": "Leads",
|
||||
"team.memberOf": "Member of",
|
||||
"team.members": "Members",
|
||||
"team.subUnits": "Sub-units",
|
||||
"team.collapse": "Collapse unit",
|
||||
"team.expand": "Expand unit",
|
||||
"team.openOrganisation": "Edit organisation",
|
||||
"nav.services": "Services",
|
||||
"nav.group.analytics": "Analytics",
|
||||
"nav.usage": "Token Usage",
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
</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">
|
||||
<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"/>
|
||||
|
||||
@@ -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() })
|
||||
}))
|
||||
])
|
||||
];
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
// ClawdDotNet — Team overview (#711)
|
||||
//
|
||||
// Who is working, who reached whom in the last seconds, and where something is stuck — at a
|
||||
// glance, like looking across an office. Stage 0: the agents of the active instance and the
|
||||
// live links between them. With units (#683) they become collapsible containers.
|
||||
// glance, like looking across an office. Stage 0 drew the agents and the live links between
|
||||
// them. Stage 1 (with #683) draws units as collapsible containers: a collapsed unit shows
|
||||
// what happens inside it, so a problem three levels down is still visible from the top.
|
||||
//
|
||||
// Three rules from the concept (Organisation-Konzept, 10a):
|
||||
// - The layout comes from the structure, not from the hand: no free node editor.
|
||||
// - The state is decided by the instance (code), the page only draws it.
|
||||
// - The state is decided by the instance (code), the page only draws it. That includes the
|
||||
// rolled-up counts of a unit — the page does not add them up.
|
||||
// - A click opens a side panel, not the chat — most problems are solved by an approval.
|
||||
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
@@ -27,6 +29,15 @@ const LINK_VISIBLE_MS = 30_000;
|
||||
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
/** Up to this many agents, every unit starts expanded; above it, only the top level shows. */
|
||||
const EXPAND_ALL_UP_TO = 15;
|
||||
|
||||
/** Unit node ids are prefixed so a unit and an agent may share a name. */
|
||||
const UNIT_PREFIX = "unit:";
|
||||
|
||||
/** Parent chains longer than this are a cycle — the instance reports it, the page must not hang. */
|
||||
const MAX_DEPTH = 64;
|
||||
|
||||
// Every state has a colour AND a symbol: colour alone is not enough.
|
||||
const STATES = {
|
||||
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
|
||||
@@ -40,7 +51,11 @@ const STATES = {
|
||||
let librariesLoaded = null;
|
||||
let cy = null;
|
||||
let snapshot = null;
|
||||
let selectedAgent = null;
|
||||
let unitsById = new Map();
|
||||
let membersById = new Map();
|
||||
let expanded = null;
|
||||
let structureKey = "";
|
||||
let selected = null;
|
||||
let panel = null;
|
||||
let summary = null;
|
||||
let unsubscribe = null;
|
||||
@@ -49,6 +64,9 @@ let fadeTimer = null;
|
||||
let refreshPending = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
/** Links as observed, agent to agent: "from->to" → { from, to, seenAt, count }. */
|
||||
const links = new Map();
|
||||
|
||||
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
|
||||
let layoutWidth = 0;
|
||||
|
||||
@@ -114,11 +132,14 @@ export async function renderTeam() {
|
||||
}
|
||||
|
||||
canvas.replaceChildren();
|
||||
indexSnapshot();
|
||||
expanded = loadExpanded();
|
||||
for (const link of snapshot.links ?? []) recordLink(link);
|
||||
drawGraph(canvas);
|
||||
|
||||
unsubscribe = abonnieren("activity", onActivity);
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS);
|
||||
fadeTimer = setInterval(fadeLinks, 1_000);
|
||||
fadeTimer = setInterval(drawLinks, 1_000);
|
||||
}
|
||||
|
||||
/** Called when the route changes: stop listening, stop timers, free the graph. */
|
||||
@@ -133,7 +154,84 @@ export function leaveTeam() {
|
||||
resizeObserver = null;
|
||||
cy?.destroy();
|
||||
cy = null;
|
||||
selectedAgent = null;
|
||||
selected = null;
|
||||
structureKey = "";
|
||||
layoutWidth = 0;
|
||||
links.clear();
|
||||
}
|
||||
|
||||
// ─── The tree ───
|
||||
|
||||
function indexSnapshot() {
|
||||
unitsById = new Map((snapshot.units ?? []).map((u) => [u.id, u]));
|
||||
membersById = new Map(snapshot.members.map((m) => [m.agentId, m]));
|
||||
}
|
||||
|
||||
/** The unit and every unit above it, nearest first; stops at a cycle. */
|
||||
function chain(unitId) {
|
||||
const result = [];
|
||||
for (let unit = unitsById.get(unitId); unit && result.length < MAX_DEPTH; unit = unitsById.get(unit.parentId)) {
|
||||
if (result.includes(unit)) break;
|
||||
result.push(unit);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Whether the unit and everything above it are expanded — then its content is drawn. */
|
||||
function isOpen(unitId) {
|
||||
return chain(unitId).every((u) => expanded.has(u.id));
|
||||
}
|
||||
|
||||
/** Whether the unit itself is drawn, as a box or as a container. */
|
||||
function isVisible(unit) {
|
||||
return !unit.parentId || !unitsById.has(unit.parentId) || isOpen(unit.parentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an agent is drawn: itself if its unit is open, otherwise the topmost collapsed unit
|
||||
* on its path. Links are drawn between these, so a collapsed unit collects the links of
|
||||
* everyone inside it.
|
||||
*/
|
||||
function representative(agentId) {
|
||||
const member = membersById.get(agentId);
|
||||
if (!member) return null;
|
||||
let rep = null;
|
||||
for (const unit of chain(member.unitId)) {
|
||||
if (!expanded.has(unit.id)) rep = unit.id;
|
||||
}
|
||||
return rep ? UNIT_PREFIX + rep : agentId;
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return `clawd.team.expanded.${holeAktiveInstanz()}`;
|
||||
}
|
||||
|
||||
function loadExpanded() {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey());
|
||||
if (stored) return new Set(JSON.parse(stored));
|
||||
} catch {
|
||||
// Private window or blocked storage: fall back to the default below.
|
||||
}
|
||||
// A small team is shown whole; a large one starts at the departments.
|
||||
return snapshot.members.length <= EXPAND_ALL_UP_TO
|
||||
? new Set(unitsById.keys())
|
||||
: new Set();
|
||||
}
|
||||
|
||||
function saveExpanded() {
|
||||
try {
|
||||
localStorage.setItem(storageKey(), JSON.stringify([...expanded]));
|
||||
} catch {
|
||||
// Only a convenience — the default applies next time.
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUnit(unitId, open) {
|
||||
if (open ?? !expanded.has(unitId)) expanded.add(unitId);
|
||||
else expanded.delete(unitId);
|
||||
saveExpanded();
|
||||
applySnapshot();
|
||||
}
|
||||
|
||||
// ─── Graph ───
|
||||
@@ -143,8 +241,9 @@ function drawGraph(canvas) {
|
||||
container: canvas,
|
||||
elements: [],
|
||||
wheelSensitivity: 0.2,
|
||||
minZoom: 0.3,
|
||||
maxZoom: 2.5,
|
||||
minZoom: 0.2,
|
||||
// A handful of collapsed units would otherwise fill the area with giant boxes.
|
||||
maxZoom: 1.6,
|
||||
// Nodes are not dragged: the layout comes from the structure, not the hand.
|
||||
autoungrabify: true,
|
||||
style: [
|
||||
@@ -168,26 +267,74 @@ function drawGraph(canvas) {
|
||||
}
|
||||
},
|
||||
{ selector: "node.aktiv", style: { "border-width": 4 } },
|
||||
{ selector: "node.leitung", style: { "font-weight": "bold" } },
|
||||
// A collapsed unit: a box that stands for everyone inside it.
|
||||
{
|
||||
selector: "node.einheit",
|
||||
style: {
|
||||
shape: "round-rectangle",
|
||||
width: 210,
|
||||
height: 70,
|
||||
"border-width": 3,
|
||||
"border-style": "double",
|
||||
"background-color": token("--surface-subtil"),
|
||||
"font-weight": "bold"
|
||||
}
|
||||
},
|
||||
{ selector: "node.einheit.aktiv", style: { "border-width": 5 } },
|
||||
// An expanded unit: a container around its members and sub-units.
|
||||
{
|
||||
selector: "node:parent",
|
||||
style: {
|
||||
shape: "round-rectangle",
|
||||
padding: 20,
|
||||
"border-width": 2,
|
||||
"border-style": "dashed",
|
||||
"background-color": token("--surface-subtil"),
|
||||
"background-opacity": 0.5,
|
||||
"text-valign": "top",
|
||||
"text-halign": "center",
|
||||
"text-margin-y": -4,
|
||||
"text-max-width": 400,
|
||||
"font-weight": "bold"
|
||||
}
|
||||
},
|
||||
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 3,
|
||||
width: "data(width)",
|
||||
"line-color": token("--accent"),
|
||||
"target-arrow-color": token("--accent"),
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
opacity: "data(opacity)"
|
||||
opacity: "data(opacity)",
|
||||
label: "data(label)",
|
||||
"font-size": 11,
|
||||
color: token("--text"),
|
||||
"text-background-color": token("--surface"),
|
||||
"text-background-opacity": 1,
|
||||
"text-background-padding": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
cy.on("tap", "node", (event) => openPanel(event.target.id()));
|
||||
cy.on("tap", "node", (event) => {
|
||||
const id = event.target.id();
|
||||
if (!id.startsWith(UNIT_PREFIX)) {
|
||||
openPanel({ agent: id });
|
||||
return;
|
||||
}
|
||||
const unitId = id.slice(UNIT_PREFIX.length);
|
||||
// A collapsed unit opens on the first click — looking inside is what the click is for.
|
||||
if (!expanded.has(unitId)) toggleUnit(unitId, true);
|
||||
openPanel({ unit: unitId });
|
||||
});
|
||||
cy.on("dbltap", "node:parent", (event) => toggleUnit(event.target.id().slice(UNIT_PREFIX.length), false));
|
||||
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
|
||||
|
||||
applySnapshot(true);
|
||||
for (const link of snapshot.links ?? []) addLink(link);
|
||||
applySnapshot();
|
||||
observeSize(canvas);
|
||||
}
|
||||
|
||||
@@ -207,58 +354,164 @@ function observeSize(canvas) {
|
||||
resizeObserver.observe(canvas);
|
||||
}
|
||||
|
||||
function runLayout() {
|
||||
/** Counts layouts, so a layout overtaken by a newer one stops trying. */
|
||||
let layoutGeneration = 0;
|
||||
|
||||
/** Runs one ELK layout with the given aspect ratio and returns the ratio it achieved. */
|
||||
function layoutOnce(aspectRatio) {
|
||||
return new Promise((resolve) => {
|
||||
const layout = cy.layout({
|
||||
name: "elk",
|
||||
fit: false,
|
||||
// The agents are packed evenly into the shape of the area, inside each container as
|
||||
// on the top level. rectpacking keeps the order they were added in (units by path,
|
||||
// the lead first), so a box does not jump around when a state changes.
|
||||
elk: {
|
||||
algorithm: "rectpacking",
|
||||
"elk.spacing.nodeNode": 40,
|
||||
"elk.aspectRatio": aspectRatio
|
||||
},
|
||||
// Room for the container's own padding and its label above the members.
|
||||
nodeLayoutOptions: (node) => node.isParent()
|
||||
? {
|
||||
"elk.algorithm": "rectpacking",
|
||||
"elk.padding": "[top=44,left=24,bottom=24,right=24]",
|
||||
"elk.spacing.nodeNode": 24
|
||||
}
|
||||
: {}
|
||||
});
|
||||
// ELK computes asynchronously; measure once the positions are there.
|
||||
layout.one("layoutstop", () => {
|
||||
const box = cy?.elements().boundingBox();
|
||||
resolve(box && box.h > 0 ? box.w / box.h : 1);
|
||||
});
|
||||
layout.run();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the graph out in the shape of the area. rectpacking treats the aspect ratio as a
|
||||
* hint and, with a few large containers on the top level, jumps between a handful of
|
||||
* shapes — asked for 1.4 it may deliver 0.6 or 2.6. So a few hints are tried and the
|
||||
* result closest to the area is kept; with the fit afterwards, that is the one that shows
|
||||
* the boxes largest.
|
||||
*/
|
||||
async function runLayout() {
|
||||
const area = cy.container();
|
||||
layoutWidth = area.clientWidth;
|
||||
const layout = cy.layout({
|
||||
name: "elk",
|
||||
fit: false,
|
||||
// Stage 0 has no structure yet, so the agents are packed evenly into the shape of
|
||||
// the area. rectpacking keeps their order, so a box does not jump around when a
|
||||
// state changes. With units (#683) this becomes a layered layout of nested containers.
|
||||
elk: {
|
||||
algorithm: "rectpacking",
|
||||
"elk.spacing.nodeNode": 40,
|
||||
"elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0
|
||||
? Math.max(0.5, area.clientWidth / area.clientHeight)
|
||||
: 2
|
||||
}
|
||||
});
|
||||
// ELK computes asynchronously; fit once the positions are there.
|
||||
layout.one("layoutstop", () => cy?.fit(undefined, 30));
|
||||
layout.run();
|
||||
const target = area.clientWidth > 0 && area.clientHeight > 0
|
||||
? Math.max(0.5, area.clientWidth / area.clientHeight)
|
||||
: 2;
|
||||
const generation = ++layoutGeneration;
|
||||
const miss = (ratio) => Math.abs(Math.log(ratio / target));
|
||||
|
||||
let best = null;
|
||||
for (const hint of [target, target * 1.2, target * 1.45, target * 0.8]) {
|
||||
const ratio = await layoutOnce(hint);
|
||||
if (!cy || generation !== layoutGeneration) return;
|
||||
if (!best || miss(ratio) < best.miss) best = { hint, miss: miss(ratio), last: true };
|
||||
else best.last = false;
|
||||
if (best.miss < 0.15) break;
|
||||
}
|
||||
// The best one may not be the last one run.
|
||||
if (!best.last) await layoutOnce(best.hint);
|
||||
if (cy && generation === layoutGeneration) cy.fit(undefined, 30);
|
||||
}
|
||||
|
||||
function nodeLabel(member) {
|
||||
function agentLabel(member) {
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
|
||||
return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
|
||||
const lead = member.isLead ? "★ " : "";
|
||||
return `${state.symbol} ${lead}${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
|
||||
}
|
||||
|
||||
function applySnapshot(relayout) {
|
||||
if (!cy || !snapshot) return;
|
||||
/** "5 agents · ▶ 2 · ⚠ 1" — everything that is not idle, most urgent first. */
|
||||
function unitCounts(unit) {
|
||||
const parts = [unit.agents === 1 ? t("team.unitAgentsOne") : t("team.unitAgents", { n: unit.agents })];
|
||||
for (const state of ["Stuck", "Problem", "WaitingForApproval", "BudgetExhausted", "Working"]) {
|
||||
const n = unit.states?.[state];
|
||||
if (n) parts.push(`${STATES[state].symbol} ${n}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
const present = new Set();
|
||||
for (const member of snapshot.members) {
|
||||
present.add(member.agentId);
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) };
|
||||
const node = cy.getElementById(member.agentId);
|
||||
if (node.nonempty()) {
|
||||
node.data(data);
|
||||
} else {
|
||||
cy.add({ group: "nodes", data });
|
||||
relayout = true;
|
||||
}
|
||||
cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
|
||||
function unitLabel(unit, open) {
|
||||
return open
|
||||
? `▾ ${unit.name} · ${unitCounts(unit)}`
|
||||
: `▸ ${unit.name}\n${unitCounts(unit)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes as they should be now, parents before children and in a stable order: agents
|
||||
* without a unit, then the units by path, each with its lead first.
|
||||
*/
|
||||
function desiredNodes() {
|
||||
const nodes = [];
|
||||
const byName = (a, b) => (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId);
|
||||
const colour = (state) => token((STATES[state] ?? STATES.Idle).colour);
|
||||
|
||||
for (const member of snapshot.members.filter((m) => !m.unitId || !unitsById.has(m.unitId)).sort(byName)) {
|
||||
nodes.push({ id: member.agentId, member, colour: colour(member.state) });
|
||||
}
|
||||
|
||||
cy.nodes().filter((n) => !present.has(n.id())).remove();
|
||||
for (const unit of snapshot.units ?? []) {
|
||||
if (!isVisible(unit)) continue;
|
||||
const open = expanded.has(unit.id);
|
||||
const parent = unit.parentId && unitsById.has(unit.parentId) ? UNIT_PREFIX + unit.parentId : undefined;
|
||||
nodes.push({ id: UNIT_PREFIX + unit.id, parent, unit, open, colour: colour(unit.worst) });
|
||||
if (!open) continue;
|
||||
|
||||
if (relayout) runLayout();
|
||||
const members = snapshot.members
|
||||
.filter((m) => m.unitId === unit.id)
|
||||
.sort((a, b) => (b.isLead - a.isLead) || byName(a, b));
|
||||
for (const member of members) {
|
||||
nodes.push({ id: member.agentId, parent: UNIT_PREFIX + unit.id, member, colour: colour(member.state) });
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the graph up to date. When only states changed, the nodes are updated in place and
|
||||
* nothing moves. When the structure changed — a unit opened or closed, an agent moved — the
|
||||
* nodes are rebuilt and laid out again: Cytoscape cannot turn a box into a container.
|
||||
*/
|
||||
function applySnapshot() {
|
||||
if (!cy || !snapshot) return;
|
||||
|
||||
const nodes = desiredNodes();
|
||||
const key = nodes.map((n) => `${n.id}<${n.parent ?? ""}${n.open ? "+" : ""}`).join("|");
|
||||
const data = (n) => ({
|
||||
id: n.id,
|
||||
parent: n.parent,
|
||||
colour: n.colour,
|
||||
label: n.unit ? unitLabel(n.unit, n.open) : agentLabel(n.member)
|
||||
});
|
||||
|
||||
if (key !== structureKey) {
|
||||
structureKey = key;
|
||||
cy.elements().remove();
|
||||
cy.add(nodes.map((n) => ({ group: "nodes", data: data(n) })));
|
||||
for (const n of nodes) decorate(n);
|
||||
drawLinks();
|
||||
runLayout();
|
||||
} else {
|
||||
for (const n of nodes) {
|
||||
const { parent: _parent, ...rest } = data(n);
|
||||
cy.getElementById(n.id).data(rest);
|
||||
decorate(n);
|
||||
}
|
||||
}
|
||||
|
||||
renderSummary();
|
||||
if (selectedAgent) openPanel(selectedAgent);
|
||||
if (selected) openPanel(selected);
|
||||
}
|
||||
|
||||
function decorate(n) {
|
||||
const node = cy.getElementById(n.id);
|
||||
node.toggleClass("einheit", Boolean(n.unit) && !n.open);
|
||||
node.toggleClass("aktiv", Boolean(n.member?.runs.length) || Boolean(n.unit && !n.open && n.unit.states?.Working));
|
||||
node.toggleClass("leitung", Boolean(n.member?.isLead));
|
||||
}
|
||||
|
||||
// ─── Live ───
|
||||
@@ -271,7 +524,10 @@ function forActiveInstance(data) {
|
||||
function onActivity(data) {
|
||||
if (!cy || !forActiveInstance(data)) return;
|
||||
|
||||
if (data.kind === "link" && data.link) addLink(data.link);
|
||||
if (data.kind === "link" && data.link) {
|
||||
recordLink(data.link);
|
||||
drawLinks();
|
||||
}
|
||||
|
||||
// Start and end change states; ask the instance instead of guessing here.
|
||||
clearTimeout(refreshPending);
|
||||
@@ -282,34 +538,65 @@ async function refresh() {
|
||||
if (!cy) return;
|
||||
try {
|
||||
snapshot = await api("/team");
|
||||
applySnapshot(false);
|
||||
indexSnapshot();
|
||||
applySnapshot();
|
||||
} catch {
|
||||
// The next event or tick tries again; a failed refresh keeps the last picture.
|
||||
}
|
||||
}
|
||||
|
||||
function addLink(link) {
|
||||
if (!cy) return;
|
||||
const age = Date.now() - new Date(link.atUtc).getTime();
|
||||
if (age > LINK_VISIBLE_MS) return;
|
||||
if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return;
|
||||
|
||||
// One edge per pair; a new contact renews it.
|
||||
const id = `link:${link.from}->${link.to}`;
|
||||
const existing = cy.getElementById(id);
|
||||
const data = { id, source: link.from, target: link.to, seenAt: new Date(link.atUtc).getTime(), opacity: 1 };
|
||||
if (existing.nonempty()) existing.data(data);
|
||||
else cy.add({ group: "edges", data });
|
||||
function recordLink(link) {
|
||||
const seenAt = new Date(link.atUtc).getTime();
|
||||
if (Date.now() - seenAt > LINK_VISIBLE_MS) return;
|
||||
const key = `${link.from}->${link.to}`;
|
||||
const known = links.get(key);
|
||||
// A new contact renews the pair and counts; the snapshot may repeat one already seen.
|
||||
if (known && known.seenAt >= seenAt) return;
|
||||
links.set(key, { from: link.from, to: link.to, seenAt, count: (known?.count ?? 0) + 1 });
|
||||
}
|
||||
|
||||
function fadeLinks() {
|
||||
/**
|
||||
* The edges between what is drawn: links of agents inside a collapsed unit start or end at
|
||||
* that unit, and several such links become one edge with a counter. A link that stays
|
||||
* inside one collapsed unit is not drawn — it is the unit talking to itself.
|
||||
*/
|
||||
function drawLinks() {
|
||||
if (!cy) return;
|
||||
const now = Date.now();
|
||||
cy.edges().forEach((edge) => {
|
||||
const age = now - edge.data("seenAt");
|
||||
if (age > LINK_VISIBLE_MS) edge.remove();
|
||||
else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS));
|
||||
});
|
||||
const bundles = new Map();
|
||||
|
||||
for (const [key, link] of links) {
|
||||
const age = now - link.seenAt;
|
||||
if (age > LINK_VISIBLE_MS) {
|
||||
links.delete(key);
|
||||
continue;
|
||||
}
|
||||
const from = representative(link.from);
|
||||
const to = representative(link.to);
|
||||
if (!from || !to || from === to) continue;
|
||||
if (cy.getElementById(from).empty() || cy.getElementById(to).empty()) continue;
|
||||
|
||||
const id = `link:${from}->${to}`;
|
||||
const bundle = bundles.get(id) ?? { id, source: from, target: to, seenAt: 0, count: 0 };
|
||||
bundle.seenAt = Math.max(bundle.seenAt, link.seenAt);
|
||||
bundle.count += link.count;
|
||||
bundles.set(id, bundle);
|
||||
}
|
||||
|
||||
cy.edges().filter((edge) => !bundles.has(edge.id())).remove();
|
||||
for (const bundle of bundles.values()) {
|
||||
const data = {
|
||||
id: bundle.id,
|
||||
source: bundle.source,
|
||||
target: bundle.target,
|
||||
opacity: Math.max(0.15, 1 - (now - bundle.seenAt) / LINK_VISIBLE_MS),
|
||||
width: Math.min(8, 2 + bundle.count),
|
||||
label: bundle.count > 1 ? `×${bundle.count}` : ""
|
||||
};
|
||||
const edge = cy.getElementById(bundle.id);
|
||||
if (edge.nonempty()) edge.data(data);
|
||||
else cy.add({ group: "edges", data });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Summary and legend ───
|
||||
@@ -334,32 +621,56 @@ function buildLegend() {
|
||||
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
|
||||
...Object.entries(STATES).map(([state, s]) =>
|
||||
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
|
||||
erstelleElement("span", { text: `★ ${t("team.legendLead")}` }),
|
||||
erstelleElement("span", { text: `▸ ${t("team.legendUnit")}` }),
|
||||
erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Side panel ───
|
||||
|
||||
function openPanel(agentId) {
|
||||
const member = snapshot?.members.find((m) => m.agentId === agentId);
|
||||
if (!member || !panel) return;
|
||||
selectedAgent = agentId;
|
||||
cy?.getElementById(agentId).select();
|
||||
function openPanel(target) {
|
||||
if (!panel || !snapshot) return;
|
||||
const rows = target.unit ? unitRows(target.unit) : agentRows(target.agent);
|
||||
if (!rows) return;
|
||||
|
||||
selected = target;
|
||||
cy?.elements().unselect();
|
||||
cy?.getElementById(target.unit ? UNIT_PREFIX + target.unit : target.agent).select();
|
||||
|
||||
panel.replaceChildren(...rows);
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function panelHead(title) {
|
||||
return erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
|
||||
erstelleElement("h2", { text: title }),
|
||||
erstelleElement("button", {
|
||||
klasse: "schaltflaeche flach klein",
|
||||
text: "×",
|
||||
title: t("common.close"),
|
||||
onclick: closePanel
|
||||
}));
|
||||
}
|
||||
|
||||
function agentRows(agentId) {
|
||||
const member = membersById.get(agentId);
|
||||
if (!member) return null;
|
||||
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
const unit = unitsById.get(member.unitId);
|
||||
const rows = [
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
|
||||
erstelleElement("h2", { text: member.displayName || member.agentId }),
|
||||
erstelleElement("button", {
|
||||
klasse: "schaltflaeche flach klein",
|
||||
text: "×",
|
||||
title: t("common.close"),
|
||||
onclick: closePanel
|
||||
})),
|
||||
panelHead(member.displayName || member.agentId),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
|
||||
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
|
||||
];
|
||||
|
||||
if (unit) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein" },
|
||||
`${member.isLead ? `★ ${t("team.leads")} ` : `${t("team.memberOf")} `}`,
|
||||
unitButton(unit)));
|
||||
}
|
||||
|
||||
// Top layer: what is happening now.
|
||||
if (member.runs.length) {
|
||||
rows.push(erstelleElement("h3", { text: t("team.running") }));
|
||||
@@ -408,9 +719,78 @@ function openPanel(agentId) {
|
||||
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
|
||||
);
|
||||
rows.push(actions);
|
||||
return rows;
|
||||
}
|
||||
|
||||
panel.replaceChildren(...rows);
|
||||
panel.hidden = false;
|
||||
function unitRows(unitId) {
|
||||
const unit = unitsById.get(unitId);
|
||||
if (!unit) return null;
|
||||
|
||||
const worst = STATES[unit.worst] ?? STATES.Idle;
|
||||
const rows = [
|
||||
panelHead(unit.name),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: unit.path }),
|
||||
erstellePlakette(`${worst.symbol} ${unitCounts(unit)}`, worst.tone)
|
||||
];
|
||||
|
||||
if (unit.goal) rows.push(erstelleElement("p", { klasse: "text-klein", text: unit.goal }));
|
||||
|
||||
if (unit.pendingApprovals > 0) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: unit.pendingApprovals })}` }));
|
||||
}
|
||||
|
||||
// Direct members, the lead first; what is further down is one click on a sub-unit away.
|
||||
rows.push(erstelleElement("h3", { text: t("team.members") }));
|
||||
const members = snapshot.members
|
||||
.filter((m) => m.unitId === unit.id)
|
||||
.sort((a, b) => (b.isLead - a.isLead) || (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId));
|
||||
if (!members.length) rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
|
||||
for (const member of members) {
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
rows.push(erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "flach klein team-eintrag",
|
||||
text: `${state.symbol} ${member.isLead ? "★ " : ""}${member.displayName || member.agentId} — ${t(`team.state.${member.state}`)}`,
|
||||
onclick: () => openPanel({ agent: member.agentId })
|
||||
}));
|
||||
}
|
||||
|
||||
const children = (snapshot.units ?? []).filter((u) => u.parentId === unit.id);
|
||||
if (children.length) {
|
||||
rows.push(erstelleElement("h3", { text: t("team.subUnits") }));
|
||||
for (const child of children) rows.push(erstelleElement("div", {}, unitButton(child)));
|
||||
}
|
||||
|
||||
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
|
||||
const open = expanded.has(unit.id);
|
||||
actions.append(
|
||||
erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "primaer klein",
|
||||
text: open ? t("team.collapse") : t("team.expand"),
|
||||
onclick: () => toggleUnit(unit.id, !open)
|
||||
}),
|
||||
link("#/organisation", t("team.openOrganisation"))
|
||||
);
|
||||
rows.push(actions);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A unit as a link in the panel, with its most urgent state. */
|
||||
function unitButton(unit) {
|
||||
const worst = STATES[unit.worst] ?? STATES.Idle;
|
||||
return erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "flach klein team-eintrag",
|
||||
text: `${worst.symbol} ${unit.name} · ${unitCounts(unit)}`,
|
||||
onclick: () => {
|
||||
// Show it in the graph too: open everything above it.
|
||||
for (const above of chain(unit.parentId)) expanded.add(above.id);
|
||||
saveExpanded();
|
||||
applySnapshot();
|
||||
openPanel({ unit: unit.id });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function link(href, text, style = "flach") {
|
||||
@@ -423,7 +803,7 @@ function link(href, text, style = "flach") {
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
selectedAgent = null;
|
||||
selected = null;
|
||||
cy?.elements().unselect();
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
|
||||
@@ -151,6 +151,12 @@ public sealed class TaskboardTool : IAgentTool
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
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
|
||||
{
|
||||
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 ───
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -97,7 +97,7 @@ public sealed record ServiceDto(
|
||||
public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = "");
|
||||
|
||||
/// <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>
|
||||
public sealed record TaskDto(
|
||||
@@ -125,8 +125,29 @@ public sealed record TaskDto(
|
||||
/// </summary>
|
||||
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
|
||||
|
||||
/// <summary>#711: The team overview — every agent and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshotDto(IReadOnlyList<TeamMemberDto> Members, IReadOnlyList<AgentLinkDto> Links, DateTime AtUtc);
|
||||
/// <summary>#711: The team overview — every agent and unit, and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshotDto(
|
||||
IReadOnlyList<TeamMemberDto> Members,
|
||||
IReadOnlyList<AgentLinkDto> Links,
|
||||
DateTime AtUtc,
|
||||
IReadOnlyList<TeamUnitDto> Units);
|
||||
|
||||
/// <summary>
|
||||
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
|
||||
/// instance. <see cref="States"/> maps a state name to a count; <see cref="Worst"/> is the
|
||||
/// most urgent state inside.
|
||||
/// </summary>
|
||||
public sealed record TeamUnitDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string? ParentId,
|
||||
string Path,
|
||||
string? LeaderAgentId,
|
||||
string? Goal,
|
||||
int Agents,
|
||||
IReadOnlyDictionary<string, int> States,
|
||||
int PendingApprovals,
|
||||
string Worst);
|
||||
|
||||
/// <summary>
|
||||
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
|
||||
@@ -142,7 +163,9 @@ public sealed record TeamMemberDto(
|
||||
string? LastRunStatus,
|
||||
DateTime? LastRunAtUtc,
|
||||
string? LastRunResult,
|
||||
long TypicalRunMs);
|
||||
long TypicalRunMs,
|
||||
string? UnitId,
|
||||
bool IsLead);
|
||||
|
||||
/// <summary>#711: A run in progress.</summary>
|
||||
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
|
||||
|
||||
@@ -74,9 +74,14 @@ internal static class Mapping
|
||||
snapshot.Members.Select(m => new TeamMemberDto(
|
||||
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
|
||||
m.Runs.Select(ToDto).ToList(),
|
||||
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(),
|
||||
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs,
|
||||
m.UnitId, m.IsLead)).ToList(),
|
||||
snapshot.RecentLinks.Select(ToDto).ToList(),
|
||||
snapshot.AtUtc);
|
||||
snapshot.AtUtc,
|
||||
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
|
||||
u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents,
|
||||
u.States.ToDictionary(s => s.Key.ToString(), s => s.Value),
|
||||
u.PendingApprovals, u.Worst.ToString())).ToList());
|
||||
|
||||
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
|
||||
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
|
||||
|
||||
@@ -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,7 @@ internal static class ApiEndpoints
|
||||
api.MapUsage();
|
||||
api.MapAnalytics();
|
||||
api.MapLogs();
|
||||
api.MapOrganisation();
|
||||
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
|
||||
|
||||
app.MapEvents();
|
||||
@@ -126,7 +127,7 @@ internal static class ApiEndpoints
|
||||
|
||||
agents.MapPost("/", (AppHost host, NewAgentDto input) =>
|
||||
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) =>
|
||||
host.AgentAdmin.Remove(agentId).ToResponse());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public sealed class TaskAdministrationTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
|
||||
try { Directory.Delete(_dir, recursive: true); }
|
||||
catch { /* Aufraeumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Activity;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
@@ -79,4 +81,85 @@ public sealed class TeamOverviewTests
|
||||
public void A_cancelled_run_is_not_a_problem()
|
||||
// Cancelled means a human stopped it — that was a decision, not a fault.
|
||||
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
|
||||
|
||||
// ═══════════════════ stage 1: units roll up ═══════════════════
|
||||
|
||||
/// <summary>marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.</summary>
|
||||
private static OrgChart Shop() => new(
|
||||
[
|
||||
new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
|
||||
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
|
||||
new UnitConfig { Id = "accounting", Name = "Accounting" }
|
||||
],
|
||||
[
|
||||
new AgentConfig { AgentId = "ceo" },
|
||||
new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" },
|
||||
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
|
||||
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
|
||||
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
|
||||
]);
|
||||
|
||||
private static TeamMember Member(string id, string? unit, TeamState state, int approvals = 0)
|
||||
=> new(id, id, "m", state, [], approvals, null, null, null, 0, unit);
|
||||
|
||||
[Fact]
|
||||
public void A_problem_deep_down_shows_on_every_container_above_it()
|
||||
{
|
||||
var units = TeamOverview.RollUp(Shop(),
|
||||
[
|
||||
Member("ceo", null, TeamState.Working),
|
||||
Member("marketing-lead", "marketing", TeamState.Working),
|
||||
Member("amazon-lead", "amazon", TeamState.Idle),
|
||||
Member("ads", "amazon", TeamState.Problem),
|
||||
Member("bookkeeper", "accounting", TeamState.Idle)
|
||||
]);
|
||||
|
||||
var marketing = units.Single(u => u.Id == "marketing");
|
||||
marketing.Agents.ShouldBe(3, "its own lead and the two in amazon below");
|
||||
marketing.Worst.ShouldBe(TeamState.Problem);
|
||||
marketing.States[TeamState.Working].ShouldBe(1);
|
||||
marketing.States[TeamState.Problem].ShouldBe(1);
|
||||
|
||||
units.Single(u => u.Id == "amazon").Worst.ShouldBe(TeamState.Problem);
|
||||
units.Single(u => u.Id == "accounting").Worst.ShouldBe(TeamState.Idle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_approvals_are_added_up_along_the_tree()
|
||||
{
|
||||
var units = TeamOverview.RollUp(Shop(),
|
||||
[
|
||||
Member("marketing-lead", "marketing", TeamState.WaitingForApproval, approvals: 1),
|
||||
Member("ads", "amazon", TeamState.WaitingForApproval, approvals: 2)
|
||||
]);
|
||||
|
||||
units.Single(u => u.Id == "marketing").PendingApprovals.ShouldBe(3);
|
||||
units.Single(u => u.Id == "amazon").PendingApprovals.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_unit_knows_its_path_and_its_lead()
|
||||
{
|
||||
var amazon = TeamOverview.RollUp(Shop(), []).Single(u => u.Id == "amazon");
|
||||
|
||||
amazon.Path.ShouldBe("Marketing / Amazon");
|
||||
amazon.LeaderAgentId.ShouldBe("amazon-lead");
|
||||
amazon.Agents.ShouldBe(0);
|
||||
amazon.Worst.ShouldBe(TeamState.Idle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stuck_is_the_most_urgent_and_idle_the_least()
|
||||
{
|
||||
var byUrgency = Enum.GetValues<TeamState>().OrderByDescending(TeamOverview.Urgency).ToList();
|
||||
|
||||
byUrgency.First().ShouldBe(TeamState.Stuck);
|
||||
byUrgency.Last().ShouldBe(TeamState.Idle);
|
||||
TeamOverview.Urgency(TeamState.Problem).ShouldBeGreaterThan(TeamOverview.Urgency(TeamState.WaitingForApproval));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_units_there_is_nothing_to_roll_up()
|
||||
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class AuditRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class BackupServiceTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
@@ -95,7 +95,7 @@ public sealed class BackupServiceTests : IDisposable
|
||||
}, default);
|
||||
|
||||
// Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft.
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_instanceDir);
|
||||
}
|
||||
|
||||
private string ZipPath => Path.Combine(_root, "sicherung.zip");
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class LicenseBackupTests : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
SecretKeyStore.UseDirectory(null);
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class BudgetGuardTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
@@ -220,7 +220,7 @@ public sealed class UsageRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
@@ -116,25 +116,27 @@ public sealed class AgentEngineConcurrencyTests
|
||||
{
|
||||
// Wichtige Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
|
||||
// gegeneinander sperren.
|
||||
var toolDelay = TimeSpan.FromMilliseconds(200);
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Slow(toolDelay));
|
||||
//
|
||||
// Bis zum 2026-09-24 antwortete die Attrappe hier nur mit Text: Das langsame
|
||||
// Werkzeug lief nie, die Läufe waren sofort fertig, und geprüft wurde nur, ob drei
|
||||
// leere Läufe unter 450 ms bleiben. Das bestand auch mit gegenseitig gesperrten
|
||||
// Agenten - und schlug unter Last fehl, weil es die Maschine mass statt der Engine.
|
||||
// Jetzt ruft jeder Agent das Werkzeug wirklich auf, und bewiesen wird die Überlappung.
|
||||
var tool = FakeTool.Slow(TimeSpan.FromMilliseconds(500));
|
||||
var fixture = new EngineFixture().WithTool(tool);
|
||||
|
||||
var a = fixture.AddAgent("agent-1", "TestTool");
|
||||
var b = fixture.AddAgent("agent-2", "TestTool");
|
||||
var c = fixture.AddAgent("agent-3", "TestTool");
|
||||
|
||||
fixture.Client.AlwaysRespondsWithText("Antwort");
|
||||
fixture.Client.RespondsContextually("TestTool");
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await Task.WhenAll(
|
||||
fixture.Engine.ChatAsync(a, "x", "test-instance", default),
|
||||
fixture.Engine.ChatAsync(b, "x", "test-instance", default),
|
||||
fixture.Engine.ChatAsync(c, "x", "test-instance", default));
|
||||
sw.Stop();
|
||||
|
||||
// Seriell wären es mindestens 3 × 200 ms. Parallel deutlich weniger.
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(450),
|
||||
"Agenten dürfen sich nicht gegenseitig blockieren");
|
||||
tool.MaxConcurrent.ShouldBeGreaterThan(1, "Agenten dürfen sich nicht gegenseitig blockieren");
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 2_000)
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class MemoryRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed class StagingRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class StagingServiceTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class WerkzeugrechteTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Releasing the pool of one database must leave every other database alone. The tests used
|
||||
/// the process-wide ClearAllPools to clean up, and a test running in parallel lost its
|
||||
/// connection in the middle of opening it — one red run in every few, always another test.
|
||||
/// </summary>
|
||||
public sealed class PoolReleaseTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "pool-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private static async Task TouchAsync(SqliteStorage storage)
|
||||
=> await storage.WriteAsync(async connection =>
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES ('k', 'v')";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}, default);
|
||||
|
||||
[Fact]
|
||||
public async Task Releasing_one_directory_frees_its_files_and_nothing_else()
|
||||
{
|
||||
var first = new SqliteStorage(Path.Combine(_root, "a", "state.db"));
|
||||
var second = new SqliteStorage(Path.Combine(_root, "b", "state.db"));
|
||||
await TouchAsync(first);
|
||||
await TouchAsync(second);
|
||||
|
||||
SqliteStorage.ReleasePooledConnections(Path.Combine(_root, "a"));
|
||||
|
||||
// The released database can go — on Windows an open handle would prevent that.
|
||||
Should.NotThrow(() => Directory.Delete(Path.Combine(_root, "a"), recursive: true));
|
||||
|
||||
// The other one keeps working, and so does its pool.
|
||||
await TouchAsync(second);
|
||||
await using var connection = await second.OpenConnectionAsync(default);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = 'k'";
|
||||
(await cmd.ExecuteScalarAsync()).ShouldBe("v");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_database_file_can_be_named_directly()
|
||||
{
|
||||
var path = Path.Combine(_root, "c", "state.db");
|
||||
await TouchAsync(new SqliteStorage(path));
|
||||
|
||||
SqliteStorage.ReleasePooledConnections(path);
|
||||
|
||||
Should.NotThrow(() => File.Delete(path));
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public sealed class BudgetMissTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class CoordinationMigrationTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class ScannerCapacityTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class TaskScannerTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class TaskboardServiceTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class PromptHistoryTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is best effort */ }
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class MemoryToolTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
|
||||
try { Directory.Delete(_dir, recursive: true); }
|
||||
catch { /* Aufraeumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public sealed class DublettenTests : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_verzeichnis);
|
||||
try { Directory.Delete(_verzeichnis, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user