Einheiten, Stufe 3: Verwaltung mit Umzug, Rechte-Obergrenze und Sammelwerten (#683)

OrgAdministration (App), damit WebUI und Desktop dieselben Regeln anwenden:
- Einheiten anlegen, umbenennen, Ziel setzen, loeschen (nur leer); Leitung
  setzen (nur ein Mitglied).
- Agent umziehen: haelt fest, seit wann und woher (UnitSinceUtc,
  PreviousUnitId); eine Leitung, die geht, laesst ihre Einheit ohne Leitung.
  Einheit umziehen: nie unter sich selbst; die Mitglieder bleiben in ihrer
  Einheit, ihre Position aendert sich, und das wird ihnen gesagt.
- Kein Umzug unter einem laufenden Agenten. Das Konzept sagt "bei gestoppter
  Instanz", damit niemand mitten im Lauf andere Rechte bekommt. Die Verwaltung
  laeuft in der Instanz und kann sie nicht stoppen; sie lehnt ab, solange ein
  betroffener Agent laeuft. Das dient demselben Zweck, nur enger.
- Rechte-Obergrenze: beim Schreiben geprueft (SetToolEnabled, SetToolSettings,
  und als Sicherheitsnetz Save() fuer den Desktop, der Werkzeuge direkt
  umschaltet). Obergrenze senken und in eine Obergrenze umziehen zeigen in
  der Vorschau, welche Werkzeuge wegfallen, und brauchen dafuer eine
  ausdrueckliche Bestaetigung.
- Sammelwerte (Tagesbudget, Tokens, Modell, whenExhausted) mit Vorschau fuer
  die Einheit samt Untereinheiten: ein Wert je Agent oder ein verteilter
  Gesamtbetrag im Verhaeltnis der bisherigen Werte. Ausreisser (Faktor 3 zum
  Median der anderen) sind markiert und standardmaessig nicht gewaehlt.
  Geschrieben wird nur, was gewaehlt ist; die Einheit merkt sich den Wert nur
  als Vorgabe fuer neue Mitglieder.
- Ein neuer Agent, der direkt in eine Einheit gelegt wird, bekommt deren
  Vorgaben; ein umgezogener behaelt seine Werte.
- Jede Strukturaenderung steht mit Begruendung im Audit (Quelle "admin",
  Werkzeug "Organisation").

Der Organisationsabschnitt nennt der Leitung jetzt auch, wer die Einheit
kuerzlich verlassen hat und wohin: Die abgebende Leitung wird unterrichtet,
nicht nur die aufnehmende.

Tests: OrgAdministrationTests (16). 1.333 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:48:34 +02:00
co-authored by Claude Opus 5.5
parent 626fc8d593
commit 68a432fec6
7 changed files with 912 additions and 2 deletions
@@ -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