Freigabe durch die Leitung innerhalb von Grenzen (#687)

- UnitConfig.Approvals: Operationen, Hoechstzahl je Tag, Stunden je Freigabe; nur der
  Mensch setzt sie (OrgAdministration.SetApprovals mit Audit, Teilmengenregel nach oben,
  Senken oben senkt unten mit; Agentenaenderungen/Sonderbudget nie freigebbar)
- LeadApprovals: Vorlage geht an die naechste Leitung, deren Einheit die Operation
  erlaubt; nie eigene Aufrufe, nie eine Leitung mit Fremdinhalt, Tagesdeckel, Frist.
  Entscheidung ueber Escalation approve_call/reject_call, im Audit als lead:<id>
- Der Mensch sieht alles und kann zuerst entscheiden; danach wird die Leitungsaufgabe
  geschlossen. Nach der Frist nur noch der Mensch.
- StagedCalls: Approver, ApproverUntil, ApproverTaskId (Nachruestung)
- WebUI: Hinweis "bei Leitung" in der Freigabestelle, Zaehler ohne diese, Quote je
  Leitung; Karte in der Organisationsansicht; i18n en/de
- Tests: LeadApprovalsTests (13), OrgAdministrationTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-30 20:12:18 +02:00
co-authored by Claude Opus 5.5
parent dafb8241a0
commit f51186269c
27 changed files with 948 additions and 20 deletions
+10 -1
View File
@@ -642,7 +642,7 @@ public sealed class AppHost : IAsyncDisposable
var (scanner, staging, board) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit,
stagingRepository, dauerfreigaben, cases, loggerFactory, logger);
stagingRepository, dauerfreigaben, stagingGate, cases, loggerFactory, logger);
var host = new AppHost
{
@@ -800,6 +800,7 @@ public sealed class AppHost : IAsyncDisposable
SqliteAuditRepository audit,
SqliteStagingRepository stagingRepository,
SqliteDauerfreigabeRepository dauerfreigaben,
StagingGate stagingGate,
Core.Cases.ICaseRepository cases,
ILoggerFactory loggerFactory,
ILogger logger)
@@ -816,6 +817,14 @@ public sealed class AppHost : IAsyncDisposable
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit,
dauerfreigaben: dauerfreigaben);
// #687: A lead within its unit's limits decides instead of the human.
var leadApprovals = new LeadApprovals(
stagingRepository, board, () => engine.OrganisationProvider?.Invoke(),
agent => Core.Organisation.ReportingLine.TrustOf(agent, tools.Get),
loggerFactory.CreateLogger("ClawdDotNet.Core.Staging.Leads")) { Service = staging };
stagingGate.Route = leadApprovals.RouteAsync;
engine.LeadApprovals = leadApprovals;
var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
tasks: taskRepository);
@@ -3,6 +3,7 @@ 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;
@@ -201,6 +202,84 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
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)}.");
}
/// <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)
@@ -806,7 +885,7 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
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
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals
};
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);