using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
///
/// #683: The organisation — units, leads, moves, tool ceilings, unit-wide values. Every
/// decision is made by ; this only translates.
///
internal static class OrganisationEndpoints
{
public static void MapOrganisation(this RouteGroupBuilder api)
{
var org = api.MapGroup("/organisation");
org.MapGet("/", (AppHost host) =>
{
var chart = host.Organisation.Current();
var units = chart.Units
.OrderBy(u => chart.PathOf(u.Id), StringComparer.OrdinalIgnoreCase)
.Select(u => new UnitDto(
u.Id, u.Name, u.ParentId, u.LeaderAgentId, u.Goal, u.ToolCeiling,
chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(),
new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted),
chart.PathOf(u.Id), chart.Depth(u.Id),
u.Approvals is { } ap ? new UnitApprovalsDto(ap.Operations, ap.PerDay, ap.Hours) : null,
u.Inspection is { } ins ? new UnitInspectionDto(ins.Rate, ins.DenseRate, ins.NewDays, ins.PerDay) : null,
u.Votes is { } v ? new UnitVotesDto(v.Operations, v.Voters, v.ApproveAt, v.RejectAt, v.EffectiveQuorum, v.Hours, v.PerDay) : null))
.ToList();
var agents = host.Instance.Agents
.OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
.Select(a => new OrgMemberDto(
a.AgentId, a.DisplayName, chart.UnitOf(a.AgentId)?.Id, chart.LedBy(a.AgentId) is not null,
chart.EffectiveLeaderOf(a.AgentId)?.AgentId, a.UnitSinceUtc,
a.Tools.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList(),
a.Budget.DailyCostUsd, a.Budget.DailyTokens, a.Model))
.ToList();
var workspaces = chart.Workspaces
.OrderBy(w => w.Id, StringComparer.OrdinalIgnoreCase)
.Select(w => new WorkspaceDto(
w.Id, w.Name, w.Purpose, w.OwnerUnitId,
ReferenceEquals(chart.UnitWorkspace(w.OwnerUnitId), w),
w.Members.Select(m => new WorkspaceMemberDto(m.UnitId, m.AgentId, m.Access.ToString(), m.IncludeSubUnits)).ToList(),
w.ProtectedPaths,
host.Organisation.FileCount(w.Id),
chart.Agents
.Select(a => (a.AgentId, Access: chart.AccessTo(a.AgentId, w.Id)))
.Where(x => x.Access > WorkspaceAccess.None)
.OrderBy(x => x.AgentId, StringComparer.OrdinalIgnoreCase)
.Select(x => new WorkspaceAccessDto(x.AgentId, x.Access.ToString()))
.ToList()))
.ToList();
return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools, workspaces);
});
org.MapPost("/units", (AppHost host, UnitInputDto input) =>
host.Organisation.CreateUnit(input.Id, input.Name, input.ParentId, input.Goal, input.Reason).ToResponse());
org.MapPatch("/units/{unitId}", (AppHost host, string unitId, UnitUpdateDto input) =>
host.Organisation.UpdateUnit(unitId, input.Name, input.Goal, input.Reason).ToResponse());
org.MapDelete("/units/{unitId}", (AppHost host, string unitId, string? reason) =>
host.Organisation.DeleteUnit(unitId, reason ?? "").ToResponse());
org.MapPut("/units/{unitId}/leader", (AppHost host, string unitId, LeaderInputDto input) =>
host.Organisation.SetLeader(unitId, input.AgentId, input.Reason).ToResponse());
org.MapPost("/units/{unitId}/move", (AppHost host, string unitId, MoveInputDto input) =>
input.Preview
? Results.Ok(ToDto(host.Organisation.PreviewMoveUnit(unitId, input.Target)))
: host.Organisation.MoveUnit(unitId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
org.MapPost("/agents/{agentId}/move", (AppHost host, string agentId, MoveInputDto input) =>
input.Preview
? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target)))
: host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
// #698: A unit's doctrine — every version, and the human setting it.
org.MapGet("/units/{unitId}/doctrine", async (AppHost host, string unitId, CancellationToken ct) =>
{
if (host.Doctrines is not { } store)
return Results.Ok(Array.Empty());
return Results.Ok((await store.HistoryAsync(unitId, ct))
.Select(d => new DoctrineDto(d.Version, d.Text, d.SetBy, d.SetAt, d.RunId, d.Reason)).ToList());
});
org.MapPut("/units/{unitId}/doctrine", async (AppHost host, string unitId, DoctrineInputDto input, CancellationToken ct) =>
{
if (host.Doctrines is not { } store)
return Results.BadRequest(new OutcomeDto(false, "Doctrines are not available in this instance."));
if (host.Organisation.Current().Unit(unitId) is not { } unit)
return Results.BadRequest(new OutcomeDto(false, $"There is no unit '{unitId}'."));
var text = (input.Text ?? "").Trim();
if (text.Length > Core.Organisation.SqliteDoctrineStore.MaxLength)
return Results.BadRequest(new OutcomeDto(false, $"A doctrine has at most {Core.Organisation.SqliteDoctrineStore.MaxLength} characters."));
var set = await store.SetAsync(unit.Id, text, "human", null, input.Reason ?? "", ct);
return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version})."));
});
// #702: What the unit puts to a blind vote, and the votes held.
org.MapPut("/units/{unitId}/votes", (AppHost host, string unitId, VotesInputDto input) =>
host.Organisation.SetVotes(unitId, input.Operations, input.Voters, input.ApproveAt, input.RejectAt, input.Quorum, input.Hours, input.PerDay, input.Reason).ToResponse());
org.MapGet("/units/{unitId}/votes", async (AppHost host, string unitId, CancellationToken ct) =>
{
if (host.Engine?.Votes is not Core.Staging.Votes votes)
return Results.Ok(Array.Empty());
var list = new List();
foreach (var vote in await votes.RecentAsync(unitId, 20, ct))
list.Add(await VoteMapping.ToDtoAsync(vote, votes, ct));
return Results.Ok(list);
});
// #693: The lead's spot checks — how often, what came of them, and the measures it proposed.
org.MapPut("/units/{unitId}/inspection", (AppHost host, string unitId, InspectionInputDto input) =>
host.Organisation.SetInspection(unitId, input.Enabled, input.Rate, input.DenseRate, input.NewDays, input.PerDay, input.Reason).ToResponse());
org.MapGet("/units/{unitId}/spot-checks", async (AppHost host, string unitId, CancellationToken ct) =>
{
if (host.SpotChecks is not { } checks)
return Results.Ok(new SpotCheckOverviewDto([], []));
var list = await checks.ForUnitAsync(unitId, 30, ct);
var measures = await checks.MeasuresForUnitAsync(unitId, 30, ct);
return Results.Ok(new SpotCheckOverviewDto(
list.Select(c => new SpotCheckDto(c.Id, c.Lead, c.AgentId, c.RunId, c.Why,
c.Findings.Select(f => new FindingDto(f.Kind.ToString(), f.Text)).ToList(),
c.CreatedAt, c.Verdict, c.Note, c.ReviewedAt)).ToList(),
measures.Select(m => new MeasureDto(m.Id, m.CheckId, m.AgentId, m.ProposedBy,
Core.Inspection.SpotChecks.Label(m.Kind), m.Reasoning, m.ProposedAt, m.Status, m.DecidedBy, m.DecidedAt, m.Note)).ToList()));
});
org.MapPost("/measures/{measureId:long}/decide", async (AppHost host, long measureId, MeasureDecisionDto input, CancellationToken ct) =>
{
if (host.SpotChecks is not { } checks)
return Results.BadRequest(new OutcomeDto(false, "Spot checks are not available in this instance."));
var decided = await checks.DecideMeasureAsync(measureId, input.Accept, input.Note, "human", ct);
return decided.Ok ? Results.Ok(new OutcomeDto(true, decided.Message)) : Results.BadRequest(new OutcomeDto(false, decided.Message));
});
// #687: What the lead may approve instead of the human.
org.MapPut("/units/{unitId}/approvals", (AppHost host, string unitId, ApprovalsInputDto input) =>
host.Organisation.SetApprovals(unitId, input.Operations, input.PerDay, input.Hours, input.Reason).ToResponse());
org.MapPut("/units/{unitId}/ceiling", (AppHost host, string unitId, CeilingInputDto input) =>
input.Preview
? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools)))
: host.Organisation.SetToolCeiling(unitId, input.Tools, input.Reason, input.DropExcessTools).ToResponse());
org.MapPost("/units/{unitId}/settings/preview", (AppHost host, string unitId, SettingPreviewInputDto input) =>
{
if (!Enum.TryParse(input.Setting, ignoreCase: true, out var setting)
|| !Enum.TryParse(input.Mode, ignoreCase: true, out var mode))
return Results.BadRequest(new OutcomeDto(false, "Unknown setting or mode."));
return Results.Ok(host.Organisation.PreviewSetting(unitId, setting, input.Value, mode)
.Select(c => new SettingChangeDto(c.AgentId, c.DisplayName, c.Current, c.Proposed, c.Outlier, c.SelectedByDefault))
.ToList());
});
MapWorkspaces(org);
org.MapPost("/units/{unitId}/settings", (AppHost host, string unitId, SettingApplyDto input) =>
Enum.TryParse(input.Setting, ignoreCase: true, out var setting)
? host.Organisation.ApplySetting(unitId, setting, input.Values, input.Reason, input.DefaultValue).ToResponse()
: Results.BadRequest(new OutcomeDto(false, "Unknown setting.")));
}
// ─── workspaces (#684) ───
private static void MapWorkspaces(RouteGroupBuilder org)
{
org.MapPost("/workspaces", (AppHost host, WorkspaceInputDto input) =>
ToMembers(input.Members ?? []) is not { } members
? Results.BadRequest(new OutcomeDto(false, "Unknown access level — Read, Write or Manage."))
: host.Organisation.CreateWorkspace(input.Id, input.Name, input.Purpose, input.OwnerUnitId, members, input.Reason).ToResponse());
org.MapPatch("/workspaces/{workspaceId}", (AppHost host, string workspaceId, WorkspaceUpdateDto input) =>
host.Organisation.UpdateWorkspace(workspaceId, input.Name, input.Purpose, input.ProtectedPaths, input.Reason).ToResponse());
org.MapPut("/workspaces/{workspaceId}/members", (AppHost host, string workspaceId, WorkspaceMembersInputDto input) =>
ToMembers(input.Members ?? []) is not { } members
? Results.BadRequest(new OutcomeDto(false, "Unknown access level — Read, Write or Manage."))
: host.Organisation.SetWorkspaceMembers(workspaceId, members, input.Reason).ToResponse());
org.MapDelete("/workspaces/{workspaceId}", (AppHost host, string workspaceId, string? reason) =>
host.Organisation.DeleteWorkspace(workspaceId, reason ?? "").ToResponse());
org.MapPost("/units/{unitId}/workspace", (AppHost host, string unitId, ReasonInputDto? input) =>
host.Organisation.CreateUnitWorkspace(unitId, input?.Reason ?? "").ToResponse());
}
/// null when a level is not one of Read, Write, Manage.
private static List? ToMembers(IReadOnlyList members)
{
var result = new List();
foreach (var member in members)
{
if (!Enum.TryParse(member.Access, ignoreCase: true, out var access) || !Enum.IsDefined(access))
return null;
result.Add(new WorkspaceMemberInput(member.UnitId, member.AgentId, access, member.IncludeSubUnits));
}
return result;
}
private static StructurePreviewDto ToDto(StructurePreview preview)
=> new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply);
}