Organisationsvorlagen: ein ganzes Haus als importierbare Datei (#691)

- Core/Organisation/OrgTemplate: format, export, parse, structural checks.
  Export empties every credential (nested, name variants) and names it;
  a template that carries one is refused.
- OrgAdministration: ExportTemplate, PreviewTemplate (problems, warnings,
  every text in full), ImportTemplateAsync - only with confirmation, units
  before agents, own workspace per unit, doctrines, model and budget factor
  overridable, templateOrigin on every unit and agent.
- Endpoints GET /organisation/template, POST .../preview, POST .../import;
  templates card in the organisation view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 12:37:18 +02:00
co-authored by Claude Opus 5.5
parent 11465f3d74
commit 706b5cfd9e
14 changed files with 880 additions and 3 deletions
@@ -42,6 +42,16 @@ public sealed record InspectionInputDto(bool Enabled, double? Rate, double? Dens
public sealed record FindingDto(string Kind, string Text);
/// <summary>#691: A template to preview or import, with what the human changes on the way in.</summary>
public sealed record TemplateImportDto(
System.Text.Json.JsonElement Template,
string? Model = null,
Dictionary<string, string>? Models = null,
decimal? BudgetFactor = null,
bool Confirm = false,
string Source = "",
string Reason = "");
/// <summary>#702: What a unit puts to a blind vote, and who votes.</summary>
public sealed record UnitVotesDto(IReadOnlyList<string> Operations, IReadOnlyList<string> Voters, int ApproveAt, int RejectAt, int Quorum, double Hours, int PerDay);
@@ -105,6 +105,26 @@ internal static class OrganisationEndpoints
return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version})."));
});
// #691: The organisation as a template file — exported without credentials, imported only after a preview and a confirmation.
org.MapGet("/template", (AppHost host, string? name, string? description) =>
Results.Text(System.Text.Json.JsonSerializer.Serialize(
host.Organisation.ExportTemplate(name ?? host.Instance.InstanceId, description ?? ""), Core.Organisation.OrgTemplates.Json),
"application/json"));
org.MapPost("/template/preview", (AppHost host, TemplateImportDto input) =>
{
if (ReadTemplate(input) is not { } template)
return Results.BadRequest(new OutcomeDto(false, TemplateError(input)));
return Results.Ok(host.Organisation.PreviewTemplate(template, Overrides(input)));
});
org.MapPost("/template/import", async (AppHost host, TemplateImportDto input, CancellationToken ct) =>
{
if (ReadTemplate(input) is not { } template)
return Results.BadRequest(new OutcomeDto(false, TemplateError(input)));
return (await host.Organisation.ImportTemplateAsync(template, Overrides(input), input.Confirm, input.Source, input.Reason, ct)).ToResponse();
});
// #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());
@@ -212,4 +232,21 @@ internal static class OrganisationEndpoints
private static StructurePreviewDto ToDto(StructurePreview preview)
=> new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply);
// ─── templates (#691) ───
private static Core.Organisation.OrgTemplate? ReadTemplate(TemplateImportDto input)
{
try { return Core.Organisation.OrgTemplates.Parse(input.Template.GetRawText()); }
catch (FormatException) { return null; }
catch (InvalidOperationException) { return null; }
}
private static string TemplateError(TemplateImportDto input)
{
try { Core.Organisation.OrgTemplates.Parse(input.Template.GetRawText()); return "The template could not be read."; }
catch (Exception ex) { return ex.Message; }
}
private static App.Services.TemplateOverrides Overrides(TemplateImportDto input)
=> new(input.Model, input.Models, input.BudgetFactor);
}