Files
ClawdDotNet/src/ClawdDotNet.App/Services/OrgAdministration.cs
T
RichardandClaude Opus 5.5 68e723a9c0 Kontrollpflicht der Leitung: Stichproben gegen das Audit (#693)
A lead looks at the runs of the agents it leads - held against the task and
against what the engine stamped, never by asking the agent.

- RunInspector (Core/Inspection): the mechanical check and first profile of
  the inspector foundation (#204). Flags failed runs, tool errors, refusals,
  unfinished calls, tasks answered with bookkeeping alone, and operations the
  agent names but never called in the run (the 2026-09-14 pattern).
- SpotChecks: picks runs per unit (rate, denser for new agents, new models and
  after a failure, daily cap), deterministic by run id; one task per lead.
- Escalation tool: spot_checks, inspect_run, check_done, propose_measure.
  Measures go to a human; nothing is carried out automatically. A security
  suspicion is its own case, not a personnel matter.
- Organisation view: settings per unit, recent checks, measures to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:40:20 +02:00

973 lines
45 KiB
C#

using System.Text.Json;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
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>#684: One entry of a workspace's member list, as the administration takes it.</summary>
public sealed record WorkspaceMemberInput(string? UnitId, string? AgentId, WorkspaceAccess Access, bool IncludeSubUnits = true);
/// <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 (!WorkspaceLayout.IsValidId(id))
return AdminResult.Failure($"'{id}' is reserved — a unit's workspace carries its id, and '{id}' already means something in FileRW.");
if (Chart.Workspace(id) is not null)
return AdminResult.Failure($"There is already a workspace '{id}'. A unit's own workspace carries the unit's id — choose another 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() ?? ""
});
// #684: every unit gets its own workspace — the normal case, it costs no decision.
host.Instance.Workspaces.Add(OwnWorkspace(id, name.Trim()));
Save(null, "unit_create", new { id, name, parentId, goal }, reason);
return AdminResult.Success($"Unit '{name.Trim()}' created, with its workspace '{id}'.");
}
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);
// #684: what was filed belongs to the unit, and the unit is gone — so nothing is
// deleted here. Its entries leave the member lists; its own workspace goes only if
// it is empty, otherwise it stays without owner for a human to decide.
var kept = new List<string>();
foreach (var workspace in host.Instance.Workspaces.ToList())
{
workspace.Members.RemoveAll(m => Same(m.UnitId, unit.Id));
if (!Same(workspace.OwnerUnitId, unit.Id))
continue;
workspace.OwnerUnitId = null;
if (Same(workspace.Id, unit.Id) && !HasFiles(workspace.Id))
host.Instance.Workspaces.Remove(workspace);
else
kept.Add(workspace.Id);
}
Save(null, "unit_delete", new { unitId, workspacesKept = kept }, reason);
return AdminResult.Success(kept.Count == 0
? $"Unit '{unit.Name}' deleted."
: $"Unit '{unit.Name}' deleted. Its workspace(s) {string.Join(", ", kept.Select(k => $"'{k}'"))} still hold files and remain, without owner.");
}
/// <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}'.");
if (!string.IsNullOrWhiteSpace(targetUnitId) && IsTopAgent(agentId))
problems.Add($"'{agentId}' is the deciding agent at the top and stands above all units. Set another top first.");
return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems);
}
// ─── approvals by the lead (#687) ───
/// <summary>
/// #687: What the lead of a unit may approve instead of the human. Only a human sets it.
/// Changes to agents are never grantable; a sub-unit gets no more than every unit above it
/// that grants anything; lowering a unit's grant lowers its sub-units' with it. <c>null</c>
/// or an empty list takes the grant away.
/// </summary>
public AdminResult SetApprovals(string unitId, IReadOnlyCollection<string>? operations, int? perDay = null, double? hours = null, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
var ops = (operations ?? []).Select(o => o.Trim()).Where(o => o.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
foreach (var op in ops)
{
var dot = op.IndexOf('.');
var tool = dot < 0 ? op : op[..dot];
var operation = dot < 0 ? null : op[(dot + 1)..];
if (tool.Length == 0 || (dot >= 0 && string.IsNullOrWhiteSpace(operation)))
return AdminResult.Failure($"'{op}' is no operation — write Tool.operation, or Tool for all of its operations.");
// A whole tool that holds such an operation is refused too — "AgentEditor" would read as a grant for it.
if (FreigabeRegeln.IstImmerFreigabe(tool, operation)
|| (operation is null && FreigabeRegeln.ImmerFreigabe.Any(e => e.StartsWith(tool + ".", StringComparison.OrdinalIgnoreCase))))
return AdminResult.Failure($"'{op}' changes agents or budgets — that always stays with a human.");
}
if (perDay is < 0)
return AdminResult.Failure("The daily cap cannot be negative.");
if (hours is <= 0)
return AdminResult.Failure("The lead's time comes in hours, above zero.");
// A sub-unit can grant no more than the units above it.
foreach (var above in Chart.Ancestors(unit.Id))
{
if (above.Approvals is not { Operations.Count: > 0 } granted)
continue;
var beyond = ops.Where(op => !Within(granted, op)).ToList();
if (beyond.Count > 0)
return AdminResult.Failure($"'{above.Name}' above grants less: {string.Join(", ", beyond)} is beyond it. Raise it there first.");
}
unit.Approvals = ops.Count == 0 ? null : new UnitApprovals
{
Operations = ops,
PerDay = perDay ?? unit.Approvals?.PerDay ?? 20,
Hours = hours ?? unit.Approvals?.Hours ?? 4
};
// What the units below grant beyond this goes with it.
var trimmed = new List<string>();
foreach (var below in Subtree(Chart, unit.Id).Where(u => !Same(u.Id, unit.Id)))
{
if (FindUnit(below.Id) is not { Approvals: { } theirs } child)
continue;
var keep = unit.Approvals is null ? [] : theirs.Operations.Where(op => Within(unit.Approvals, op)).ToList();
if (keep.Count == theirs.Operations.Count)
continue;
trimmed.Add(child.Name);
child.Approvals = keep.Count == 0 ? null : new UnitApprovals { Operations = keep, PerDay = theirs.PerDay, Hours = theirs.Hours };
}
Save(null, "unit_approvals", new { unitId, operations = ops, perDay = unit.Approvals?.PerDay, hours = unit.Approvals?.Hours, trimmed }, reason);
var message = unit.Approvals is null
? $"The lead of '{unit.Name}' approves nothing now; a human decides."
: $"The lead of '{unit.Name}' may approve {string.Join(", ", ops)} (up to {unit.Approvals.PerDay} a day, {unit.Approvals.Window.TotalHours:0.##} h each).";
return AdminResult.Success(trimmed.Count == 0 ? message : message + $" Lowered with it: {string.Join(", ", trimmed)}.");
}
// ─── spot checks (#693) ───
/// <summary>
/// #693: How often the lead of a unit looks at the runs of the agents it leads. Only a human
/// sets it — every check costs the lead a look, and the cap is what keeps the control from
/// costing more than the work.
/// </summary>
public AdminResult SetInspection(string unitId, bool enabled, double? rate = null, double? denseRate = null,
int? newDays = null, int? perDay = null, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
if (!enabled)
{
unit.Inspection = null;
Save(null, "unit_inspection", new { unitId, enabled }, reason);
return AdminResult.Success($"The lead of '{unit.Name}' takes no spot checks now.");
}
if (rate is < 0 or > 1 || denseRate is < 0 or > 1)
return AdminResult.Failure("A share lies between 0 and 1.");
if (perDay is < 0 or > 100)
return AdminResult.Failure("Checks a day lie between 0 and 100.");
if (newDays is < 0 or > 365)
return AdminResult.Failure("An agent counts as new for 0 to 365 days.");
var current = unit.Inspection ?? new UnitInspection();
unit.Inspection = new UnitInspection
{
Rate = rate ?? current.Rate,
DenseRate = denseRate ?? current.DenseRate,
NewDays = newDays ?? current.NewDays,
PerDay = perDay ?? current.PerDay
};
if (unit.Inspection.DenseRate < unit.Inspection.Rate)
unit.Inspection.DenseRate = unit.Inspection.Rate;
var lead = Chart.LeaderOf(unit.Id);
var i = unit.Inspection;
Save(null, "unit_inspection", new { unitId, enabled, i.Rate, i.DenseRate, i.NewDays, i.PerDay }, reason);
return AdminResult.Success(
$"The lead of '{unit.Name}' checks {i.Rate:P0} of the runs ({i.DenseRate:P0} for new agents, new models and after a failure), at most {i.PerDay} a day."
+ (lead is null ? " The unit has no lead: its agents are checked under the settings of the unit whose lead they answer to." : ""));
}
/// <summary>Whether a grant covers an operation string — "Tool" covers every "Tool.x".</summary>
private static bool Within(UnitApprovals granted, string op)
{
var dot = op.IndexOf('.');
return dot < 0
? granted.Operations.Any(g => Same(g, op))
: granted.Covers(op[..dot], op[(dot + 1)..]);
}
// ─── the top (#690) ───
private bool IsTopAgent(string agentId)
=> host.Instance.Cases.Top.UsesAgent && Same(host.Instance.Cases.Top.AgentId?.Trim(), agentId);
/// <summary>
/// #690: Who decides at the top — a human, an agent, or the human first with the agent behind.
/// An agent has to fit the role (<see cref="TopRole.Unfit"/>) when it is set; a change of
/// the top is a change of rights and goes into the audit log with its reason.
/// </summary>
public AdminResult SetTop(TopMode mode, string? agentId, double? humanFirstHours = null, string reason = "")
{
agentId = NullIfEmpty(agentId);
if (mode != TopMode.Human)
{
if (agentId is null)
return AdminResult.Failure("Name the agent that decides at the top.");
if (TopRole.Unfit(host.AgentAdmin.Find(agentId), agentId) is { } unfit)
return AdminResult.Failure(unfit);
agentId = host.AgentAdmin.Find(agentId)!.AgentId;
}
if (humanFirstHours is <= 0)
return AdminResult.Failure("The time the human has comes in hours, above zero.");
var top = host.Instance.Cases.Top;
top.Mode = mode;
top.AgentId = mode == TopMode.Human ? null : agentId;
if (humanFirstHours is { } hours)
top.HumanFirstHours = hours;
host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
Audit(agentId ?? "", "top_set", new { mode = mode.ToString(), agentId, top.HumanFirstHours }, reason);
return AdminResult.Success(mode switch
{
TopMode.Human => "A human decides at the top.",
TopMode.Agent => $"'{agentId}' decides at the top. Its decisions are presented to you to read.",
_ => $"A human decides at the top first; after {top.HumanFirst.TotalHours:0.##} h without an answer, '{agentId}' decides and presents it."
});
}
/// <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)
{
// #690: The deciding agent decides, it does not carry out.
if (IsTopAgent(agent.AgentId) && !TopRole.AllowedTools.Contains(toolName))
return $"'{agent.AgentId}' is the deciding agent at the top and may not hold '{toolName}' — it decides, it does not carry out. " +
$"Allowed are {string.Join(", ", TopRole.AllowedTools.Order(StringComparer.OrdinalIgnoreCase))}.";
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;
}
}
// ─── workspaces (#684) ───
/// <summary>
/// A workspace shared across units on purpose — the article master that purchasing owns
/// and marketing reads. It needs a named owner or none (then it belongs to the human),
/// and its id must not be a unit's: those are kept for the units' own workspaces.
/// </summary>
public AdminResult CreateWorkspace(
string id, string name, string? purpose, string? ownerUnitId,
IReadOnlyList<WorkspaceMemberInput> members, string reason = "")
{
id = (id ?? "").Trim();
if (!WorkspaceLayout.IsValidId(id))
return AdminResult.Failure($"A workspace id consists of letters, digits, '-' and '_', and is none of {string.Join(", ", WorkspaceLayout.Reserved)}.");
if (string.IsNullOrWhiteSpace(name))
return AdminResult.Failure("A workspace needs a name.");
if (Chart.Workspace(id) is not null)
return AdminResult.Failure($"There is already a workspace '{id}'.");
if (Chart.Unit(id) is not null)
return AdminResult.Failure($"'{id}' is the id of a unit — its own workspace carries it. Choose another id.");
if (!string.IsNullOrWhiteSpace(ownerUnitId) && FindUnit(ownerUnitId) is null)
return AdminResult.Failure($"There is no unit '{ownerUnitId}' to own it.");
if (CheckMembers(members) is { } problem)
return AdminResult.Failure(problem);
host.Instance.Workspaces.Add(new WorkspaceConfig
{
Id = id,
Name = name.Trim(),
Purpose = purpose?.Trim() ?? "",
OwnerUnitId = string.IsNullOrWhiteSpace(ownerUnitId) ? null : FindUnit(ownerUnitId)!.Id,
Members = members.Select(ToMember).ToList()
});
Save(null, "workspace_create", new { id, name, ownerUnitId, members }, reason);
return AdminResult.Success($"Workspace '{name.Trim()}' created.");
}
/// <summary>Name, purpose and protected folders. <c>null</c> leaves a value as it is.</summary>
public AdminResult UpdateWorkspace(
string workspaceId, string? name, string? purpose, IReadOnlyList<string>? protectedPaths, string reason = "")
{
if (FindWorkspace(workspaceId) is not { } workspace)
return AdminResult.Failure($"There is no workspace '{workspaceId}'.");
if (name is not null && string.IsNullOrWhiteSpace(name))
return AdminResult.Failure("A workspace needs a name.");
var paths = protectedPaths?
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
.Where(p => p.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (paths?.FirstOrDefault(p => p.Split('/').Any(part => part is "." or "..")) is { } bad)
return AdminResult.Failure($"'{bad}' is not a folder inside the workspace.");
if (name is not null) workspace.Name = name.Trim();
if (purpose is not null) workspace.Purpose = purpose.Trim();
if (paths is not null) workspace.ProtectedPaths = paths;
Save(null, "workspace_update", new { workspaceId, name, purpose, protectedPaths = paths }, reason);
return AdminResult.Success($"Workspace '{workspace.Name}' saved.");
}
/// <summary>
/// Replaces the member list. The right stands here, at the workspace — whoever is not on
/// the list does not see it. Works for a unit's own workspace too: widening it to a
/// reading neighbour is exactly the kind of decision that belongs to a human.
/// </summary>
public AdminResult SetWorkspaceMembers(string workspaceId, IReadOnlyList<WorkspaceMemberInput> members, string reason = "")
{
if (FindWorkspace(workspaceId) is not { } workspace)
return AdminResult.Failure($"There is no workspace '{workspaceId}'.");
if (CheckMembers(members) is { } problem)
return AdminResult.Failure(problem);
var before = workspace.Members.Select(m => new { m.UnitId, m.AgentId, m.Access, m.IncludeSubUnits }).ToList();
workspace.Members = members.Select(ToMember).ToList();
Save(null, "workspace_members", new { workspaceId, before, after = members }, reason);
return AdminResult.Success($"Members of '{workspace.Name}' saved.");
}
/// <summary>
/// Only an empty workspace can be deleted: nothing filed is ever thrown away from here.
/// A unit's own workspace goes with its unit, not on its own.
/// </summary>
public AdminResult DeleteWorkspace(string workspaceId, string reason = "")
{
if (FindWorkspace(workspaceId) is not { } workspace)
return AdminResult.Failure($"There is no workspace '{workspaceId}'.");
if (Chart.UnitWorkspace(workspace.OwnerUnitId) == workspace)
return AdminResult.Failure($"'{workspace.Id}' is the own workspace of unit '{workspace.OwnerUnitId}' — it goes when the unit is deleted.");
if (HasFiles(workspace.Id))
return AdminResult.Failure($"Workspace '{workspace.Name}' still holds files. Nothing filed is deleted from here — move or remove them first.");
host.Instance.Workspaces.Remove(workspace);
Save(null, "workspace_delete", new { workspaceId }, reason);
return AdminResult.Success($"Workspace '{workspace.Name}' deleted.");
}
/// <summary>
/// Gives a unit its own workspace if it has none — for units that existed before
/// workspaces did. Does nothing for a unit that has one.
/// </summary>
public AdminResult CreateUnitWorkspace(string unitId, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
if (Chart.UnitWorkspace(unit.Id) is not null)
return AdminResult.Success($"'{unit.Name}' already has its workspace.");
if (Chart.Workspace(unit.Id) is not null)
return AdminResult.Failure($"There is a workspace '{unit.Id}' that does not belong to the unit. Rename or delete it first.");
if (!WorkspaceLayout.IsValidId(unit.Id))
return AdminResult.Failure($"The unit id '{unit.Id}' cannot be a workspace id.");
host.Instance.Workspaces.Add(OwnWorkspace(unit.Id, unit.Name));
Save(null, "workspace_create", new { id = unit.Id, ownerUnitId = unit.Id }, reason);
return AdminResult.Success($"'{unit.Name}' has its workspace now.");
}
/// <summary>The folder of a workspace; <c>null</c> when the instance has no shared folder.</summary>
public string? WorkspaceFolder(string workspaceId)
=> WorkspaceLayout.PathOf(
host.Instance.Agents.Select(a => a.SharedWorkspacePath).FirstOrDefault(p => !string.IsNullOrWhiteSpace(p)),
workspaceId);
/// <summary>How many files a workspace holds, in all its folders.</summary>
public int FileCount(string workspaceId)
=> WorkspaceFolder(workspaceId) is { } folder && Directory.Exists(folder)
? Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Count()
: 0;
private bool HasFiles(string workspaceId) => FileCount(workspaceId) > 0;
private static WorkspaceConfig OwnWorkspace(string unitId, string unitName) => new()
{
Id = unitId,
Name = unitName,
OwnerUnitId = unitId,
Members = [new WorkspaceMember { UnitId = unitId, Access = WorkspaceAccess.Write, IncludeSubUnits = true }]
};
private string? CheckMembers(IReadOnlyList<WorkspaceMemberInput> members)
{
foreach (var member in members)
{
var byUnit = !string.IsNullOrWhiteSpace(member.UnitId);
var byAgent = !string.IsNullOrWhiteSpace(member.AgentId);
if (byUnit == byAgent)
return "Each member is either a unit or an agent.";
if (byUnit && FindUnit(member.UnitId) is null)
return $"There is no unit '{member.UnitId}'.";
if (byAgent && host.AgentAdmin.Find(member.AgentId!) is null)
return $"There is no agent '{member.AgentId}'.";
if (member.Access == WorkspaceAccess.None)
return "A member needs at least read access — leave it off the list instead.";
}
return null;
}
private WorkspaceMember ToMember(WorkspaceMemberInput input) => new()
{
UnitId = string.IsNullOrWhiteSpace(input.UnitId) ? null : FindUnit(input.UnitId)!.Id,
AgentId = string.IsNullOrWhiteSpace(input.AgentId) ? null : host.AgentAdmin.Find(input.AgentId!)!.AgentId,
Access = input.Access,
IncludeSubUnits = input.IncludeSubUnits
};
private WorkspaceConfig? FindWorkspace(string? workspaceId)
=> workspaceId is null ? null : host.Instance.Workspaces.FirstOrDefault(w => Same(w.Id, workspaceId));
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, Approvals = u.Approvals,
Inspection = u.Inspection
};
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);
}
}
}