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:
co-authored by
Claude Opus 5.5
parent
dafb8241a0
commit
f51186269c
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user