using System.Text.Json;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
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);
}
// ─── 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
};
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);
}
}
}