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
@@ -28,6 +28,13 @@ public sealed class AgentConfig
[JsonPropertyName("previousUnitId")]
public string? PreviousUnitId { get; set; }
/// <summary>
/// #691: The template the agent came from. Its Identity and Soul were written by someone
/// else — they are prompts, and where they came from must stay visible.
/// </summary>
[JsonPropertyName("templateOrigin")]
public string? TemplateOrigin { get; set; }
[JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
@@ -70,6 +70,10 @@ public sealed class UnitConfig
/// </summary>
[JsonPropertyName("votes")]
public UnitVotes? Votes { get; set; }
/// <summary>#691: The template the unit came from — its texts and rules are someone else's.</summary>
[JsonPropertyName("templateOrigin")]
public string? TemplateOrigin { get; set; }
}
/// <summary>
@@ -0,0 +1,277 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Security;
namespace ClawdDotNet.Core.Organisation;
/// <summary>
/// #691: A whole house as a file — units and their nesting, the agents with their texts, tools,
/// models and budgets, the units' rules and doctrines. Exported from one instance, passed on,
/// imported into another.
///
/// <para><b>The test of the building kit.</b> Nothing in the product says "purchasing": a
/// department is a unit with a name, and whether it is purchasing, an editorial office or a
/// lab is a line in this file. A template that cannot be written without code means a use
/// case slipped into the product.</para>
///
/// <para><b>Never credentials.</b> A secret is taken out on export and named instead
/// (<see cref="Credentials"/>); the import says which ones are needed and leaves them empty.</para>
/// </summary>
public sealed class OrgTemplate
{
public const string FormatName = "clawddotnet-org-template";
public const int CurrentFormatVersion = 1;
[JsonPropertyName("format")]
public string Format { get; set; } = FormatName;
[JsonPropertyName("formatVersion")]
public int FormatVersion { get; set; } = CurrentFormatVersion;
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
/// <summary>The template's own version, as its author counts.</summary>
[JsonPropertyName("version")]
public string Version { get; set; } = "1";
[JsonPropertyName("exportedAt")]
public DateTime? ExportedAt { get; set; }
[JsonPropertyName("units")]
public List<UnitConfig> Units { get; set; } = [];
[JsonPropertyName("agents")]
public List<TemplateAgent> Agents { get; set; } = [];
/// <summary>The doctrine of each unit, by unit id.</summary>
[JsonPropertyName("doctrines")]
public Dictionary<string, string> Doctrines { get; set; } = [];
/// <summary>What has to be filled in after the import — never the values.</summary>
[JsonPropertyName("credentials")]
public List<CredentialNeed> Credentials { get; set; } = [];
}
/// <summary>#691: An agent as a template carries it: its role, not its state.</summary>
public sealed class TemplateAgent
{
[JsonPropertyName("agentId")]
public string AgentId { get; set; } = "";
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("unitId")]
public string? UnitId { get; set; }
[JsonPropertyName("model")]
public string Model { get; set; } = "";
[JsonPropertyName("budget")]
public AgentBudget Budget { get; set; } = new();
/// <summary>Tool settings with every secret emptied.</summary>
[JsonPropertyName("tools")]
public Dictionary<string, JsonObject> Tools { get; set; } = [];
[JsonPropertyName("identity")]
public string Identity { get; set; } = "";
[JsonPropertyName("soul")]
public string Soul { get; set; } = "";
}
/// <summary>A credential the template needs: which agent, which tool, which field.</summary>
public sealed record CredentialNeed(
[property: JsonPropertyName("agentId")] string AgentId,
[property: JsonPropertyName("tool")] string Tool,
[property: JsonPropertyName("field")] string Field);
public static class OrgTemplates
{
/// <summary>Longest Identity or Soul text a template may bring — it goes into every prompt.</summary>
public const int MaxTextLength = 50_000;
private static readonly Regex IdPattern = new("^[a-z0-9][a-z0-9_-]{0,63}$", RegexOptions.IgnoreCase);
public static readonly JsonSerializerOptions Json = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// The instance's organisation as a template. Secrets are emptied and named; workspaces,
/// histories, tasks and everything an agent learned stay behind — a template is a structure.
/// </summary>
public static OrgTemplate Export(InstanceConfig instance, string name, string description, Func<string, string?> doctrineOf, DateTime now)
{
var template = new OrgTemplate { Name = name.Trim(), Description = description.Trim(), ExportedAt = now };
foreach (var unit in instance.Units)
{
template.Units.Add(JsonSerializer.Deserialize<UnitConfig>(JsonSerializer.Serialize(unit))!);
if (doctrineOf(unit.Id) is { Length: > 0 } doctrine)
template.Doctrines[unit.Id] = doctrine;
}
foreach (var agent in instance.Agents)
{
var entry = new TemplateAgent
{
AgentId = agent.AgentId,
DisplayName = agent.DisplayName,
Description = agent.Description,
UnitId = agent.UnitId,
Model = agent.Model,
Budget = new AgentBudget { DailyCostUsd = agent.Budget.DailyCostUsd, DailyTokens = agent.Budget.DailyTokens, WhenExhausted = agent.Budget.WhenExhausted },
Identity = agent.Identity,
Soul = agent.Soul
};
foreach (var (tool, settings) in agent.Tools)
{
var node = JsonSerializer.SerializeToNode(settings)?.AsObject() ?? new JsonObject();
foreach (var field in StripSecrets(node, ""))
template.Credentials.Add(new CredentialNeed(agent.AgentId, tool, field));
entry.Tools[tool] = node;
}
template.Agents.Add(entry);
}
return template;
}
/// <summary>Whether a setting's name says it holds a credential — also nested and in variants (<c>smtpPassword</c>).</summary>
public static bool IsSecretName(string key)
{
if (ConfigSecrets.IsSecretKey(key))
return true;
var k = key.ToLowerInvariant();
return k.Contains("password") || k.Contains("passwort") || k.Contains("secret") || k.Contains("token")
|| k.Contains("apikey") || k.Contains("api_key") || k.Contains("connectionstring") || k.Contains("credential")
|| k.Contains("privatekey");
}
/// <summary>Empties every secret in the tree, returns the paths of those that held a value.</summary>
internal static List<string> StripSecrets(JsonNode? node, string path)
{
var found = new List<string>();
switch (node)
{
case JsonObject obj:
foreach (var (key, value) in obj.ToList())
{
var here = path.Length == 0 ? key : $"{path}.{key}";
if (IsSecretName(key) && value is JsonValue)
{
if (value.ToString().Length > 0)
found.Add(here);
obj[key] = "";
}
else
found.AddRange(StripSecrets(value, here));
}
break;
case JsonArray array:
for (var i = 0; i < array.Count; i++)
found.AddRange(StripSecrets(array[i], $"{path}[{i}]"));
break;
}
return found;
}
public static OrgTemplate Parse(string json)
{
OrgTemplate? template;
try
{
// The file must say what it is — the class's default would let any JSON pass.
using (var doc = JsonDocument.Parse(json))
if (doc.RootElement.ValueKind != JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("format", out var format)
|| format.ValueKind != JsonValueKind.String || format.GetString() != OrgTemplate.FormatName)
template = null;
else
template = JsonSerializer.Deserialize<OrgTemplate>(json, Json);
}
catch (JsonException ex) { throw new FormatException($"Not a template: {ex.Message}"); }
if (template is null)
throw new FormatException($"Not a template — the file does not say \"format\": \"{OrgTemplate.FormatName}\".");
if (template.FormatVersion > OrgTemplate.CurrentFormatVersion)
throw new FormatException($"The template is of format version {template.FormatVersion}; this version reads up to {OrgTemplate.CurrentFormatVersion}.");
return template;
}
/// <summary>What is wrong with the template itself — before it is held against an instance.</summary>
public static IReadOnlyList<string> Problems(OrgTemplate template)
{
var problems = new List<string>();
var units = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var u in template.Units)
{
if (!IdPattern.IsMatch(u.Id ?? ""))
problems.Add($"Unit id '{u.Id}' is not valid.");
else if (!units.Add(u.Id))
problems.Add($"Unit '{u.Id}' appears twice.");
if (string.IsNullOrWhiteSpace(u.Name))
problems.Add($"Unit '{u.Id}' has no name.");
}
foreach (var u in template.Units.Where(u => u.ParentId is not null && !units.Contains(u.ParentId)))
problems.Add($"Unit '{u.Id}' hangs under '{u.ParentId}', which the template does not have.");
foreach (var u in template.Units)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var p = u; p?.ParentId is { } parent; p = template.Units.FirstOrDefault(x => string.Equals(x.Id, parent, StringComparison.OrdinalIgnoreCase)))
if (!seen.Add(parent))
{
problems.Add($"Unit '{u.Id}' lies in a circle.");
break;
}
}
var agents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var a in template.Agents)
{
if (!IdPattern.IsMatch(a.AgentId ?? ""))
problems.Add($"Agent id '{a.AgentId}' is not valid.");
else if (!agents.Add(a.AgentId))
problems.Add($"Agent '{a.AgentId}' appears twice.");
if (string.IsNullOrWhiteSpace(a.DisplayName))
problems.Add($"Agent '{a.AgentId}' has no display name.");
else if (!names.Add(a.DisplayName.Trim()))
problems.Add($"Display name '{a.DisplayName}' appears twice — it names the agent's folder.");
if (a.UnitId is not null && !units.Contains(a.UnitId))
problems.Add($"Agent '{a.AgentId}' belongs to unit '{a.UnitId}', which the template does not have.");
if (a.Identity.Length > MaxTextLength || a.Soul.Length > MaxTextLength)
problems.Add($"Agent '{a.AgentId}' brings a text longer than {MaxTextLength} characters.");
foreach (var (tool, settings) in a.Tools)
if (StripSecrets(settings.DeepClone(), "").Count > 0)
problems.Add($"Agent '{a.AgentId}' brings a credential for {tool} — a template never carries one.");
}
foreach (var u in template.Units)
{
if (u.LeaderAgentId is { Length: > 0 } lead
&& template.Agents.FirstOrDefault(a => string.Equals(a.AgentId, lead, StringComparison.OrdinalIgnoreCase)) is var leader
&& (leader is null || !string.Equals(leader.UnitId, u.Id, StringComparison.OrdinalIgnoreCase)))
problems.Add($"Unit '{u.Id}' is led by '{lead}', who is not one of its members in the template.");
foreach (var voter in u.Votes?.Voters ?? [])
if (!agents.Contains(voter))
problems.Add($"Unit '{u.Id}' lets '{voter}' vote, whom the template does not have.");
}
foreach (var unitId in template.Doctrines.Keys.Where(k => !units.Contains(k)))
problems.Add($"A doctrine is given for '{unitId}', which the template does not have.");
return problems;
}
}