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; /// A unit-wide value that can be written into the members (#683). public enum UnitSetting { DailyCostUsd, DailyTokens, Model, WhenExhausted } /// How a unit-wide number is turned into per-agent values. public enum UnitSettingMode { /// Every agent gets the value ("each gets 10 $"). PerAgent, /// The value is a total, split in proportion to the current values ("the unit gets 100 $"). Distribute } /// One line of a preview: what would change at one agent. public sealed record UnitSettingChange( string AgentId, string DisplayName, string Current, string Proposed, bool Outlier, bool SelectedByDefault); /// #684: One entry of a workspace's member list, as the administration takes it. public sealed record WorkspaceMemberInput(string? UnitId, string? AgentId, WorkspaceAccess Access, bool IncludeSubUnits = true); /// What a move or a lowered ceiling would do before it is done. public sealed record StructurePreview( IReadOnlyList AffectedAgents, IReadOnlyDictionary> ToolsLost, IReadOnlyList Running, IReadOnlyList Problems) { public bool CanApply => Running.Count == 0 && Problems.Count == 0; } /// /// #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 ). /// /// Three rules from the concept (section 3): /// /// No value overrides another at run time. 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. /// The tool ceiling is checked when writing. A tool beyond it cannot be /// granted; lowering it removes the tools from the agents, after a preview. /// A move never happens under a running agent. 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. /// /// /// Every structural change is written to the audit log with its reason — a change of /// structure is a change of rights (#205, #641). /// public sealed class OrgAdministration(AppHost host, ILogger logger) { /// A value this far from the median of the others is an outlier. 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."); } /// Only an empty unit can go — members and sub-units have to be moved first. 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(); 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."); } /// The lead must be a member of the unit. null leaves it without a lead. 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(); 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) ─── /// /// #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. null /// or an empty list takes the grant away. /// public AdminResult SetApprovals(string unitId, IReadOnlyCollection? 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(); 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) ─── /// /// #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. /// 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." : "")); } /// Whether a grant covers an operation string — "Tool" covers every "Tool.x". 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); /// /// #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 () when it is set; a change of /// the top is a change of rights and goes into the audit log with its reason. /// 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." }); } /// /// Moves an agent. Tools beyond the new unit's ceiling are removed only with /// — otherwise the move is refused and says which. /// A lead leaving its unit leaves it without a lead. /// 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(); 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); } /// /// 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. /// 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? tools) { var chart = Chart; var problems = new List(); 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)); } /// /// Sets the ceiling. Lowering it removes the tools beyond it from the members — shown by /// first, and only with , /// like a move. null lifts the ceiling. /// public AdminResult SetToolCeiling( string unitId, IReadOnlyCollection? 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)}."); } /// /// Whether an agent may be granted a tool — called by /// when writing. null means yes; otherwise the reason. /// 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 ─── /// /// 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. /// public IReadOnlyList 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 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(); } /// /// Writes the values chosen from a preview into the agents, one audit entry each, and — /// with — keeps the value as the unit's default for /// agents that join. /// public AdminResult ApplySetting( string unitId, UnitSetting setting, IReadOnlyDictionary 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."); } /// /// 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. /// 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 agents, string? targetUnitId, (string UnitId, string? NewParent)? movingUnit, List problems, (string UnitId, IReadOnlyCollection? 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>(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 Subtree(OrgChart chart, string unitId) { if (chart.Unit(unitId) is not { } root) yield break; var stack = new Stack([root]); var seen = new HashSet(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 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) ─── /// /// 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. /// public AdminResult CreateWorkspace( string id, string name, string? purpose, string? ownerUnitId, IReadOnlyList 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."); } /// Name, purpose and protected folders. null leaves a value as it is. public AdminResult UpdateWorkspace( string workspaceId, string? name, string? purpose, IReadOnlyList? 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."); } /// /// 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. /// public AdminResult SetWorkspaceMembers(string workspaceId, IReadOnlyList 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."); } /// /// 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. /// 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."); } /// /// 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. /// 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."); } /// The folder of a workspace; null when the instance has no shared folder. public string? WorkspaceFolder(string workspaceId) => WorkspaceLayout.PathOf( host.Instance.Agents.Select(a => a.SharedWorkspacePath).FirstOrDefault(p => !string.IsNullOrWhiteSpace(p)), workspaceId); /// How many files a workspace holds, in all its folders. 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 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); } } }