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
@@ -330,6 +330,82 @@ public sealed class OrgAdministrationTests : IDisposable
Unit("marketing").Votes.ShouldBeNull();
}
// ═══════════════════ templates (#691) ═══════════════════
private static Core.Organisation.OrgTemplate Shop() => new()
{
Name = "Shop", Version = "2",
Units =
[
new UnitConfig { Id = "shop", Name = "Shop", LeaderAgentId = "shop-lead", ToolCeiling = ["Taskboard", "WebFetch"] },
new UnitConfig { Id = "returns", Name = "Returns", ParentId = "shop" }
],
Agents =
[
new Core.Organisation.TemplateAgent { AgentId = "shop-lead", DisplayName = "Shop Lead", UnitId = "shop", Model = "m/large",
Identity = "You run the shop.", Soul = "Calm.", Budget = new AgentBudget { DailyCostUsd = 4 },
Tools = new() { ["Taskboard"] = new System.Text.Json.Nodes.JsonObject() } },
new Core.Organisation.TemplateAgent { AgentId = "clerk", DisplayName = "Clerk", UnitId = "returns", Model = "m/small",
Tools = new() { ["WebFetch"] = new System.Text.Json.Nodes.JsonObject { ["maxKb"] = 64 } } }
],
Credentials = [new("clerk", "WebFetch", "apiKey")]
};
[Fact]
public async Task A_template_is_imported_only_after_confirmation_with_its_origin_and_overrides()
{
(await Org.ImportTemplateAsync(Shop(), null, confirmed: false, "shop.json", "", default)).Message.ShouldContain("Confirm");
_test.Host.Instance.Units.ShouldBeEmpty("nothing is written before the confirmation");
var imported = await Org.ImportTemplateAsync(Shop(), new TemplateOverrides(Model: "m/cheap", BudgetFactor: 0.5m), true, "shop.json", "trial", default);
imported.Ok.ShouldBeTrue(imported.Message);
imported.Message.ShouldContain("1 credential");
Unit("returns").ParentId.ShouldBe("shop");
Unit("shop").TemplateOrigin!.ShouldStartWith("Shop v2");
_test.Host.Instance.Workspaces.ShouldContain(w => w.Id == "returns", "every unit gets its own workspace");
var lead = Find("shop-lead");
lead.Identity.ShouldBe("You run the shop.");
lead.Model.ShouldBe("m/cheap");
lead.Budget.DailyCostUsd.ShouldBe(2);
lead.TemplateOrigin!.ShouldContain("shop.json");
Org.Current().LeaderOf("shop")!.AgentId.ShouldBe("shop-lead");
Find("clerk").Tools.Keys.ShouldBe(["WebFetch"]);
}
[Fact]
public void A_template_that_clashes_or_breaks_a_ceiling_is_refused_in_the_preview()
{
BuildMarketing();
var shop = Shop();
shop.Agents.Add(new Core.Organisation.TemplateAgent { AgentId = "ads", DisplayName = "Ads 2", UnitId = "shop" });
shop.Agents[0].Tools["Database"] = new System.Text.Json.Nodes.JsonObject();
shop.Units.Add(new UnitConfig { Id = "marketing", Name = "Marketing again" });
var preview = Org.PreviewTemplate(shop);
preview.CanImport.ShouldBeFalse();
preview.Problems.ShouldContain(p => p.Contains("agent 'ads'"));
preview.Problems.ShouldContain(p => p.Contains("unit 'marketing'"));
preview.Problems.ShouldContain(p => p.Contains("Database, beyond the tool ceiling"));
preview.Warnings.ShouldContain(p => p.Contains("become prompts"));
}
[Fact]
public void The_export_of_an_instance_is_a_template_another_can_preview()
{
BuildMarketing();
Find("ads").Tools["WebFetch"]["token"] = "secret-value";
var exported = Org.ExportTemplate("Marketing");
var json = System.Text.Json.JsonSerializer.Serialize(exported, Core.Organisation.OrgTemplates.Json);
json.ShouldNotContain("secret-value");
exported.Credentials.ShouldContain(c => c.AgentId == "ads" && c.Field == "token");
Core.Organisation.OrgTemplates.Problems(Core.Organisation.OrgTemplates.Parse(json)).ShouldBeEmpty();
Org.PreviewTemplate(exported).Problems.ShouldContain(p => p.Contains("already has"), "into the same instance it clashes");
}
// ═══════════════════ the top (#690) ═══════════════════
private void MakeDecider(string id)
@@ -0,0 +1,102 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Organisation;
/// <summary>
/// #691: A whole house as a file — never with credentials, and a broken file is refused before
/// it touches an instance.
/// </summary>
public sealed class OrgTemplateTests
{
private static InstanceConfig House()
{
var instance = new InstanceConfig();
instance.Units.Add(new UnitConfig { Id = "newsroom", Name = "Newsroom", LeaderAgentId = "editor", Goal = "Publish daily." });
instance.Units.Add(new UnitConfig { Id = "desk", Name = "Desk", ParentId = "newsroom" });
instance.Agents.Add(new AgentConfig
{
AgentId = "editor", DisplayName = "Editor", UnitId = "newsroom", Model = "m/large",
Identity = "You lead the newsroom.", Soul = "Careful.",
Budget = new AgentBudget { DailyCostUsd = 2 },
Tools = new()
{
["Mail"] = new() { ["host"] = "mail.example.org", ["password"] = "hunter2", ["smtpPassword"] = "x" },
["DirectAPI"] = new()
{
["providers"] = JsonSerializer.Deserialize<JsonElement>("""{"weather":{"url":"https://w.example","apiKey":"k-123"}}""")
}
}
});
instance.Agents.Add(new AgentConfig { AgentId = "writer", DisplayName = "Writer", UnitId = "desk", Model = "m/small" });
return instance;
}
private static OrgTemplate Export() => OrgTemplates.Export(House(), "Newsroom", "A small newsroom.",
unit => unit == "newsroom" ? "Every claim has a source." : null, DateTime.UtcNow);
[Fact]
public void Export_empties_every_credential_and_names_it_instead()
{
var template = Export();
var json = JsonSerializer.Serialize(template, OrgTemplates.Json);
json.ShouldNotContain("hunter2");
json.ShouldNotContain("k-123");
json.ShouldContain("mail.example.org", customMessage: "what is not secret stays");
template.Credentials.Select(c => $"{c.AgentId}:{c.Tool}:{c.Field}").ShouldBe(
["editor:Mail:password", "editor:Mail:smtpPassword", "editor:DirectAPI:providers.weather.apiKey"], ignoreOrder: true);
}
[Fact]
public void Export_carries_the_structure_texts_and_doctrines()
{
var template = OrgTemplates.Parse(JsonSerializer.Serialize(Export(), OrgTemplates.Json));
template.Units.Select(u => u.Id).ShouldBe(["newsroom", "desk"]);
template.Agents.Single(a => a.AgentId == "editor").Identity.ShouldBe("You lead the newsroom.");
template.Doctrines["newsroom"].ShouldBe("Every claim has a source.");
OrgTemplates.Problems(template).ShouldBeEmpty();
}
[Fact]
public void A_file_that_is_no_template_is_refused()
{
Should.Throw<FormatException>(() => OrgTemplates.Parse("""{"units":[]}"""));
Should.Throw<FormatException>(() => OrgTemplates.Parse("""{"format":"clawddotnet-org-template","formatVersion":99}"""));
Should.Throw<FormatException>(() => OrgTemplates.Parse("not json"));
}
[Fact]
public void A_broken_structure_is_named_before_anything_is_written()
{
var template = Export();
template.Units.Add(new UnitConfig { Id = "a", Name = "A", ParentId = "b" });
template.Units.Add(new UnitConfig { Id = "b", Name = "B", ParentId = "a" });
template.Units.Add(new UnitConfig { Id = "orphan", Name = "Orphan", ParentId = "nowhere" });
template.Units[1].LeaderAgentId = "editor";
template.Agents.Add(new TemplateAgent { AgentId = "writer", DisplayName = "Writer 2" });
template.Agents[0].Tools["Mail"]["password"] = "smuggled";
var problems = OrgTemplates.Problems(template);
problems.ShouldContain(p => p.Contains("circle"));
problems.ShouldContain(p => p.Contains("'nowhere'"));
problems.ShouldContain(p => p.Contains("led by 'editor'"));
problems.ShouldContain(p => p.Contains("'writer' appears twice"));
problems.ShouldContain(p => p.Contains("never carries one"));
}
[Fact]
public void A_nested_secret_is_found_under_any_variant_of_its_name()
{
var node = JsonNode.Parse("""{"a":{"clientSecret":"s","list":[{"accessToken":"t"}],"name":"keep"}}""")!;
OrgTemplates.StripSecrets(node, "").ShouldBe(["a.clientSecret", "a.list[0].accessToken"]);
node.ToJsonString().ShouldContain("keep");
node.ToJsonString().ShouldNotContain("\"s\"");
}
}