diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md index e4e3ed4..09359b2 100644 --- a/docs/Organisation-Konzept.md +++ b/docs/Organisation-Konzept.md @@ -59,6 +59,21 @@ weitergebbare Datei. Dieselbe Idee wie bei den Basisagenten hier ein ganzes Haus. Wer eine Vorlage „Onlinehandel" oder „Redaktion" schreibt, hat damit keinen Code geschrieben. +> **Umgesetzt (#691), Stufe 1.** Das Format ist JSON (`"format": "clawddotnet-org-template"`): +> Einheiten mit allen Regeln (Werkzeugdecke, Vorgaben, Freigaben, Stichproben, +> Abstimmungen), Doktrinen, Agenten mit Identity, Soul, Modell, Budget und +> Werkzeugeinstellungen. Der Export **leert jedes Zugangsdatum**, auch verschachtelte und +> Namensvarianten (`smtpPassword`, `providers.x.apiKey`), und benennt es in `credentials`; +> eine Vorlage, die doch eines mitbringt, wird abgewiesen. Der Import zeigt erst eine +> Vorschau — Probleme (Kreise, fehlende Eltern, Leitung ohne Mitgliedschaft, IDs oder +> Namen, die die Instanz schon hat, Werkzeuge über der Decke), alle Texte in voller Länge +> — und läuft nur nach ausdrücklicher Bestätigung, dass diese Texte zu Prompts werden. +> Modell und Budgetfaktor sind beim Import überschreibbar. Jede Einheit und jeder Agent +> trägt die Herkunft (`templateOrigin`: Name, Version, Fingerabdruck, Datei). Offen: +> Aufgaben und benannte Arbeitsbereiche in der Vorlage; Nachziehen einer neuen Fassung +> ohne angepasste Einheiten zu überschreiben (Stufe 3 wie #679); die Modellprüfung beim +> Import, sobald es die Werkzeugproben (#681) gibt. + --- ## 2 — Was heute bei 50 Agenten bricht diff --git a/src/ClawdDotNet.App/Services/OrgAdministration.Templates.cs b/src/ClawdDotNet.App/Services/OrgAdministration.Templates.cs new file mode 100644 index 0000000..7fbf87f --- /dev/null +++ b/src/ClawdDotNet.App/Services/OrgAdministration.Templates.cs @@ -0,0 +1,186 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; + +namespace ClawdDotNet.App.Services; + +/// #691: What the human may change when importing a template — models and budgets fit one house, not every house. +public sealed record TemplateOverrides( + string? Model = null, + IReadOnlyDictionary? Models = null, + decimal? BudgetFactor = null); + +public sealed record TemplatePreviewUnit(string Id, string Name, string? ParentId, string? LeaderAgentId, string Path); + +public sealed record TemplatePreviewAgent( + string AgentId, string DisplayName, string? UnitId, string Model, decimal DailyCostUsd, long DailyTokens, + IReadOnlyList Tools, string Identity, string Soul); + +/// #691: What an import would do — and what stands in its way. Nothing is written for it. +public sealed record TemplatePreview( + string Name, + string Version, + string Fingerprint, + IReadOnlyList Problems, + IReadOnlyList Warnings, + IReadOnlyList Units, + IReadOnlyList Agents, + IReadOnlyList Credentials) +{ + public bool CanImport => Problems.Count == 0; +} + +public sealed partial class OrgAdministration +{ + // ─── templates (#691) ─── + + /// The organisation of this instance as a template file — never with credentials. + public OrgTemplate ExportTemplate(string name, string description = "") + => OrgTemplates.Export(host.Instance, string.IsNullOrWhiteSpace(name) ? "Organisation" : name, description, + unitId => host.Doctrines?.Current(unitId)?.Text, DateTime.UtcNow); + + /// + /// What importing would create, with the models and budgets after + /// the overrides — and the problems that stop it: a broken template, ids or names the + /// instance already has, tools beyond a unit's ceiling. + /// + public TemplatePreview PreviewTemplate(OrgTemplate template, TemplateOverrides? overrides = null) + { + overrides ??= new TemplateOverrides(); + var problems = OrgTemplates.Problems(template).ToList(); + var sound = problems.Count == 0; + var warnings = new List(); + + foreach (var unit in template.Units) + { + if (FindUnit(unit.Id) is not null) + problems.Add($"This instance already has a unit '{unit.Id}'."); + else if (Chart.Workspace(unit.Id) is not null) + problems.Add($"This instance already has a workspace '{unit.Id}' — the unit's own workspace would carry that id."); + else if (!WorkspaceLayout.IsValidId(unit.Id)) + problems.Add($"'{unit.Id}' is reserved and cannot be a unit id."); + } + foreach (var agent in template.Agents) + { + if (host.AgentAdmin.Find(agent.AgentId) is not null) + problems.Add($"This instance already has an agent '{agent.AgentId}'."); + else if (host.Instance.Agents.Any(a => Same(a.DisplayName.Trim(), agent.DisplayName.Trim()))) + problems.Add($"This instance already has an agent named '{agent.DisplayName}' — the name gives the folder."); + } + if (overrides.BudgetFactor is <= 0) + problems.Add("The budget factor must be above 0."); + + // The ceilings of the template's own units apply to the template's own agents — walked + // only in a sound tree: a circle would never end. + var chart = new OrgChart(sound ? template.Units : [], template.Agents.Select(a => new AgentConfig { AgentId = a.AgentId, UnitId = a.UnitId })); + foreach (var agent in template.Agents) + { + if (sound && chart.ToolCeiling(agent.UnitId) is { } ceiling) + foreach (var tool in agent.Tools.Keys.Where(t => !ceiling.Contains(t))) + problems.Add($"Agent '{agent.AgentId}' has {tool}, beyond the tool ceiling of its unit."); + foreach (var tool in agent.Tools.Keys.Where(t => host.Tools.Get(t) is null)) + warnings.Add($"{tool} (agent '{agent.AgentId}') is not loaded in this instance — the setting is kept, the tool does nothing."); + } + if (template.Credentials.Count > 0) + warnings.Add($"{template.Credentials.Count} credential(s) must be filled in after the import — the template brings none."); + warnings.Add("The Identity and Soul texts of this template become prompts. Read them: whoever wrote the template writes your agents' instructions."); + + var units = template.Units + .Select(u => new TemplatePreviewUnit(u.Id, u.Name, u.ParentId, u.LeaderAgentId, sound ? chart.PathOf(u.Id) : u.Name)) + .OrderBy(u => u.Path, StringComparer.OrdinalIgnoreCase) + .ToList(); + var agents = template.Agents.Select(a => + { + var budget = Scaled(a.Budget, overrides.BudgetFactor); + return new TemplatePreviewAgent(a.AgentId, a.DisplayName, a.UnitId, ModelFor(a, overrides), + budget.DailyCostUsd, budget.DailyTokens, a.Tools.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList(), a.Identity, a.Soul); + }).ToList(); + + return new TemplatePreview(template.Name, template.Version, Fingerprint(template), problems, warnings, units, agents, template.Credentials); + } + + /// + /// Imports the template: units with their rules and workspaces, agents with their texts, + /// tools, models and budgets, the doctrines. Only with — the + /// texts are someone else's instructions — and only if the preview has no problem. Every + /// unit and agent keeps the template as its origin; credentials stay empty. + /// + public async Task ImportTemplateAsync(OrgTemplate template, TemplateOverrides? overrides, bool confirmed, string source, string reason, CancellationToken ct) + { + overrides ??= new TemplateOverrides(); + var preview = PreviewTemplate(template, overrides); + if (!preview.CanImport) + return AdminResult.Failure("The template cannot be imported: " + string.Join(" ", preview.Problems.Take(5))); + if (!confirmed) + return AdminResult.Failure("Confirm the import: the template's Identity and Soul texts become your agents' prompts."); + + var origin = $"{template.Name} v{template.Version} ({preview.Fingerprint})" + (string.IsNullOrWhiteSpace(source) ? "" : $" from {source.Trim()}"); + + // Parents before children, so every unit finds the one it hangs under. + var chart = new OrgChart(template.Units, []); + foreach (var unit in template.Units.OrderBy(u => chart.Depth(u.Id))) + { + var copy = JsonSerializer.Deserialize(JsonSerializer.Serialize(unit))!; + copy.TemplateOrigin = origin; + host.Instance.Units.Add(copy); + host.Instance.Workspaces.Add(OwnWorkspace(copy.Id, copy.Name)); + } + + var created = new List(); + foreach (var agent in template.Agents) + { + var model = ModelFor(agent, overrides); + var added = host.AgentAdmin.Add(new NewAgent(agent.AgentId, agent.DisplayName, model, agent.Description, agent.UnitId)); + if (!added.Ok || host.AgentAdmin.Find(agent.AgentId) is not { } config) + return AdminResult.Failure($"Stopped at agent '{agent.AgentId}': {added.Message} Created so far: {string.Join(", ", created)}."); + + // The template's values, not the unit's defaults — the template says what it means. + config.Model = model; + config.Budget = Scaled(agent.Budget, overrides.BudgetFactor); + config.Identity = agent.Identity; + config.Soul = agent.Soul; + config.TemplateOrigin = origin; + config.Tools = agent.Tools.ToDictionary( + t => t.Key, + t => JsonSerializer.Deserialize>(t.Value.ToJsonString()) ?? new Dictionary(), + StringComparer.OrdinalIgnoreCase); + host.AgentAdmin.PersistAgent(config); + created.Add(agent.AgentId); + } + + if (host.Doctrines is { } doctrines) + foreach (var (unitId, text) in template.Doctrines) + if (!string.IsNullOrWhiteSpace(text)) + await doctrines.SetAsync(unitId, text.Length > SqliteDoctrineStore.MaxLength ? text[..SqliteDoctrineStore.MaxLength] : text, + "template", null, $"Imported with {origin}", ct); + + Save(null, "template_import", new + { + origin, units = template.Units.Select(u => u.Id), agents = created, + overrides.Model, overrides.BudgetFactor, credentials = template.Credentials.Count + }, reason); + + return AdminResult.Success( + $"Imported '{template.Name}': {template.Units.Count} unit(s), {created.Count} agent(s)." + + (template.Credentials.Count == 0 ? "" : $" Fill in {template.Credentials.Count} credential(s) in the agents' tool settings before they run.")); + } + + private static string ModelFor(TemplateAgent agent, TemplateOverrides overrides) + => overrides.Models?.GetValueOrDefault(agent.AgentId) is { Length: > 0 } own ? own.Trim() + : !string.IsNullOrWhiteSpace(overrides.Model) ? overrides.Model.Trim() + : string.IsNullOrWhiteSpace(agent.Model) ? new AgentConfig().Model + : agent.Model; + + private static AgentBudget Scaled(AgentBudget budget, decimal? factor) => new() + { + DailyCostUsd = factor is { } f ? Math.Round(budget.DailyCostUsd * f, 2) : budget.DailyCostUsd, + DailyTokens = factor is { } g ? (long)Math.Round(budget.DailyTokens * g) : budget.DailyTokens, + WhenExhausted = budget.WhenExhausted + }; + + /// A short fingerprint of the template's content — which version of it came in. + private static string Fingerprint(OrgTemplate template) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(template, OrgTemplates.Json))))[..12].ToLowerInvariant(); +} diff --git a/src/ClawdDotNet.App/Services/OrgAdministration.cs b/src/ClawdDotNet.App/Services/OrgAdministration.cs index 30da681..39b51fb 100644 --- a/src/ClawdDotNet.App/Services/OrgAdministration.cs +++ b/src/ClawdDotNet.App/Services/OrgAdministration.cs @@ -71,7 +71,7 @@ public sealed record StructurePreview( /// Every structural change is written to the audit log with its reason — a change of /// structure is a change of rights (#205, #641). /// -public sealed class OrgAdministration(AppHost host, ILogger logger) +public sealed partial class OrgAdministration(AppHost host, ILogger logger) { /// A value this far from the median of the others is an outlier. public const decimal OutlierFactor = 3m; @@ -1004,7 +1004,7 @@ public sealed class OrgAdministration(AppHost host, ILogger logger) { Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId, Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals, - Inspection = u.Inspection, Votes = u.Votes + Inspection = u.Inspection, Votes = u.Votes, TemplateOrigin = u.TemplateOrigin }; private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs index af34618..2e0f07c 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -28,6 +28,13 @@ public sealed class AgentConfig [JsonPropertyName("previousUnitId")] public string? PreviousUnitId { get; set; } + /// + /// #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. + /// + [JsonPropertyName("templateOrigin")] + public string? TemplateOrigin { get; set; } + [JsonPropertyName("model")] public string Model { get; set; } = "anthropic/claude-sonnet-4-5"; diff --git a/src/ClawdDotNet.Core/Config/UnitConfig.cs b/src/ClawdDotNet.Core/Config/UnitConfig.cs index 31def91..e21c84e 100644 --- a/src/ClawdDotNet.Core/Config/UnitConfig.cs +++ b/src/ClawdDotNet.Core/Config/UnitConfig.cs @@ -70,6 +70,10 @@ public sealed class UnitConfig /// [JsonPropertyName("votes")] public UnitVotes? Votes { get; set; } + + /// #691: The template the unit came from — its texts and rules are someone else's. + [JsonPropertyName("templateOrigin")] + public string? TemplateOrigin { get; set; } } /// diff --git a/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs b/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs new file mode 100644 index 0000000..7fee933 --- /dev/null +++ b/src/ClawdDotNet.Core/Organisation/OrgTemplate.cs @@ -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; + +/// +/// #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. +/// +/// The test of the building kit. 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. +/// +/// Never credentials. A secret is taken out on export and named instead +/// (); the import says which ones are needed and leaves them empty. +/// +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; } = ""; + + /// The template's own version, as its author counts. + [JsonPropertyName("version")] + public string Version { get; set; } = "1"; + + [JsonPropertyName("exportedAt")] + public DateTime? ExportedAt { get; set; } + + [JsonPropertyName("units")] + public List Units { get; set; } = []; + + [JsonPropertyName("agents")] + public List Agents { get; set; } = []; + + /// The doctrine of each unit, by unit id. + [JsonPropertyName("doctrines")] + public Dictionary Doctrines { get; set; } = []; + + /// What has to be filled in after the import — never the values. + [JsonPropertyName("credentials")] + public List Credentials { get; set; } = []; +} + +/// #691: An agent as a template carries it: its role, not its state. +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(); + + /// Tool settings with every secret emptied. + [JsonPropertyName("tools")] + public Dictionary Tools { get; set; } = []; + + [JsonPropertyName("identity")] + public string Identity { get; set; } = ""; + + [JsonPropertyName("soul")] + public string Soul { get; set; } = ""; +} + +/// A credential the template needs: which agent, which tool, which field. +public sealed record CredentialNeed( + [property: JsonPropertyName("agentId")] string AgentId, + [property: JsonPropertyName("tool")] string Tool, + [property: JsonPropertyName("field")] string Field); + +public static class OrgTemplates +{ + /// Longest Identity or Soul text a template may bring — it goes into every prompt. + 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 + }; + + /// + /// 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. + /// + public static OrgTemplate Export(InstanceConfig instance, string name, string description, Func 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(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; + } + + /// Whether a setting's name says it holds a credential — also nested and in variants (smtpPassword). + 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"); + } + + /// Empties every secret in the tree, returns the paths of those that held a value. + internal static List StripSecrets(JsonNode? node, string path) + { + var found = new List(); + 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(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; + } + + /// What is wrong with the template itself — before it is held against an instance. + public static IReadOnlyList Problems(OrgTemplate template) + { + var problems = new List(); + var units = new HashSet(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(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(StringComparer.OrdinalIgnoreCase); + var names = new HashSet(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; + } +} diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index 607c1d1..790fa03 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -2637,6 +2637,19 @@ button.org-einheit .org-name { gap: 4px; } +/* #691: a template before it comes in — every agent with its full texts. */ +.org-vorlage-agent { + border-left: 3px solid var(--accent); + padding: 6px 12px; + margin-bottom: 10px; +} + +.org-vorlage-agent pre { + max-height: 240px; + overflow: auto; + white-space: pre-wrap; +} + /* #702: a vote beside the call — every ballot with its reasoning and evidence. */ .staging-stimmen { list-style: none; diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 8b8f565..dbf05e5 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -152,6 +152,23 @@ "org.voteTallyText": "{yes} ja · {no} nein · {missing} fehlen", "org.voteOutcome": "Ergebnis", "org.voteCost": "Kosten der Abstimmenden", + "org.templates": "Organisationsvorlagen", + "org.templatesHint": "Ein ganzes Haus als Datei: Einheiten, Regeln, Doktrinen und Agenten mit ihren Texten, Werkzeugen, Modellen und Budgets. Exportiert ohne Zugangsdaten — sie werden benannt, nie mitgegeben. Importiert nur in IDs und Namen, die diese Instanz noch nicht hat, und erst, nachdem du gelesen hast, was hereinkommt.", + "org.templateNamePlaceholder": "Name der Vorlage", + "org.templateExport": "Exportieren", + "org.templateExportedCredentials": "Exportiert. {n} Zugangsdaten wurden weggelassen und sind in der Datei benannt.", + "org.templateFile": "Vorlage importieren", + "org.templateModel": "Modell für alle Agenten (optional)", + "org.templateModelPlaceholder": "Modelle der Vorlage behalten", + "org.templateBudgetFactor": "Budgetfaktor", + "org.templateProblems": "Die Vorlage lässt sich nicht importieren:", + "org.templateUnits": "{n} Einheit(en)", + "org.templateAgents": "{n} Agent(en)", + "org.templateDay": "Tag", + "org.templateCredentials": "Danach einzutragende Zugangsdaten", + "org.templateConfirm": "Ich habe die Identity- und Soul-Texte gelesen. Sie werden zu den Prompts meiner Agenten.", + "org.templateImport": "Importieren", + "org.templateUnreadable": "Die Datei ist keine Vorlage: {error}", "org.ceilingInherited": "Von oben begrenzt auf: {tools}", "org.settings": "Sammelwerte", "org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index e3671b4..3c8891c 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -152,6 +152,23 @@ "org.voteTallyText": "{yes} yes · {no} no · {missing} missing", "org.voteOutcome": "Outcome", "org.voteCost": "Cost of the voters", + "org.templates": "Organisation templates", + "org.templatesHint": "A whole house as a file: units, rules, doctrines and agents with their texts, tools, models and budgets. Exported without credentials — they are named, never carried. Imported only into ids and names this instance does not have yet, after you have read what comes in.", + "org.templateNamePlaceholder": "Template name", + "org.templateExport": "Export", + "org.templateExportedCredentials": "Exported. {n} credential(s) were left out and are named in the file.", + "org.templateFile": "Import a template", + "org.templateModel": "Model for all agents (optional)", + "org.templateModelPlaceholder": "keep the template's models", + "org.templateBudgetFactor": "Budget factor", + "org.templateProblems": "The template cannot be imported:", + "org.templateUnits": "{n} unit(s)", + "org.templateAgents": "{n} agent(s)", + "org.templateDay": "day", + "org.templateCredentials": "Credentials to fill in afterwards", + "org.templateConfirm": "I have read the Identity and Soul texts. They become my agents' prompts.", + "org.templateImport": "Import", + "org.templateUnreadable": "The file is no template: {error}", "org.ceilingInherited": "Limited from above to: {tools}", "org.settings": "Unit-wide values", "org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js index 3d4eeb7..e008ce5 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js @@ -307,7 +307,10 @@ function renderDetail() { ], { klasse: "org-grund" }); if (!item) { - detailBox.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") })); + detailBox.replaceChildren( + erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") }), + reasonCard, + templatesCard()); return; } @@ -317,6 +320,119 @@ function renderDetail() { detailBox.replaceChildren(reasonCard, ...panels); } +/** + * #691: The organisation as a template file. Export never carries credentials; import shows + * everything first — units, agents, models, budgets and the full Identity and Soul texts, which + * become prompts — and only runs after an explicit confirmation. + */ +function templatesCard() { + const name = erstelleElement("input", { type: "text", placeholder: t("org.templateNamePlaceholder") }); + const file = erstelleElement("input", { type: "file", accept: ".json,application/json" }); + const model = erstelleElement("input", { type: "text", placeholder: t("org.templateModelPlaceholder") }); + const factor = erstelleElement("input", { type: "number", min: "0.01", step: "0.1", value: "1" }); + const result = erstelleElement("div", { klasse: "org-vorlage" }); + let template = null; + let source = ""; + + const body = () => ({ + template, + model: model.value.trim() || null, + budgetFactor: Number(factor.value) > 0 && Number(factor.value) !== 1 ? Number(factor.value) : null, + source, + reason: reason() + }); + + const exportTemplate = async () => { + try { + const tpl = await api(`/organisation/template?name=${encodeURIComponent(name.value.trim())}`); + const blob = new Blob([JSON.stringify(tpl, null, 2)], { type: "application/json" }); + const link = erstelleElement("a", { href: URL.createObjectURL(blob) }); + link.download = `${(name.value.trim() || "organisation").replace(/[^\w-]+/g, "_")}.org-template.json`; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 1000); + if (tpl.credentials?.length) zeigeMeldung(t("org.templateExportedCredentials", { n: tpl.credentials.length }), "erfolg", 8000); + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + } + }; + + const preview = async () => { + if (!template) return; + let p; + try { + p = await api("/organisation/template/preview", { method: "POST", body: body() }); + } catch (err) { + result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: err.message })); + return; + } + + const confirm = erstelleElement("input", { type: "checkbox" }); + const run = erstelleElement("button", { + type: "button", klasse: "primaer klein", text: t("org.templateImport"), disabled: true, + onclick: async () => { + const ok = await send("/organisation/template/import", "POST", { ...body(), confirm: confirm.checked }); + if (ok) { template = null; result.replaceChildren(); file.value = ""; } + } + }); + confirm.addEventListener("change", () => { run.disabled = !confirm.checked || !p.canImport; }); + + result.replaceChildren( + erstelleElement("h4", { text: `${p.name} · v${p.version} · ${p.fingerprint}` }), + p.problems.length + ? erstelleElement("div", { klasse: "org-probleme" }, + erstelleElement("strong", { text: t("org.templateProblems") }), + erstelleElement("ul", {}, ...p.problems.map((x) => erstelleElement("li", { text: x })))) + : "", + ...p.warnings.map((w) => erstelleElement("p", { klasse: "hinweis-box warnung text-klein", text: w })), + erstelleElement("h4", { text: t("org.templateUnits", { n: p.units.length }) }), + erstelleElement("ul", { klasse: "org-liste" }, ...p.units.map((u) => erstelleElement("li", { klasse: "text-klein", + text: `${u.path}${u.leaderAgentId ? ` · ${t("org.leadShort")}: ${u.leaderAgentId}` : ""}` }))), + erstelleElement("h4", { text: t("org.templateAgents", { n: p.agents.length }) }), + ...p.agents.map((a) => erstelleElement("div", { klasse: "org-vorlage-agent" }, + erstelleElement("strong", { text: `${a.displayName} (${a.agentId})` }), + erstelleElement("div", { klasse: "gedaempft text-klein", + text: `${a.unitId ?? t("org.topLevel")} · ${a.model} · $${a.dailyCostUsd}/${t("org.templateDay")} · ${a.tools.join(", ") || "—"}` }), + erstelleElement("div", { klasse: "text-klein", text: "Identity" }), + erstelleElement("pre", { klasse: "code-block", text: a.identity || "—" }), + erstelleElement("div", { klasse: "text-klein", text: "Soul" }), + erstelleElement("pre", { klasse: "code-block", text: a.soul || "—" }))), + p.credentials.length + ? erstelleElement("div", {}, + erstelleElement("h4", { text: t("org.templateCredentials") }), + erstelleElement("ul", { klasse: "org-liste" }, ...p.credentials.map((c) => erstelleElement("li", { klasse: "text-klein", + text: `${c.agentId} · ${c.tool} · ${c.field}` })))) + : "", + erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, confirm, erstelleElement("span", { text: t("org.templateConfirm") })), + run); + }; + + file.addEventListener("change", async () => { + const f = file.files?.[0]; + if (!f) return; + try { + template = JSON.parse(await f.text()); + source = f.name; + await preview(); + } catch (err) { + template = null; + result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: t("org.templateUnreadable", { error: err.message }) })); + } + }); + model.addEventListener("change", preview); + factor.addEventListener("change", preview); + + return erstelleKarte(t("org.templates"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.templatesHint") }), + erstelleElement("div", { klasse: "gruppe-horizontal" }, name, + erstelleElement("button", { type: "button", klasse: "klein", text: t("org.templateExport"), onclick: exportTemplate })), + erstelleElement("div", { klasse: "formular-raster" }, + erstelleElement("label", { text: t("org.templateFile") }), file, + erstelleElement("label", { text: t("org.templateModel") }), model, + erstelleElement("label", { text: t("org.templateBudgetFactor") }), factor), + result + ]); +} + function unitPanel(unit) { const cards = []; diff --git a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs index 6a74966..9dc17dc 100644 --- a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs +++ b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs @@ -42,6 +42,16 @@ public sealed record InspectionInputDto(bool Enabled, double? Rate, double? Dens public sealed record FindingDto(string Kind, string Text); +/// #691: A template to preview or import, with what the human changes on the way in. +public sealed record TemplateImportDto( + System.Text.Json.JsonElement Template, + string? Model = null, + Dictionary? Models = null, + decimal? BudgetFactor = null, + bool Confirm = false, + string Source = "", + string Reason = ""); + /// #702: What a unit puts to a blind vote, and who votes. public sealed record UnitVotesDto(IReadOnlyList Operations, IReadOnlyList Voters, int ApproveAt, int RejectAt, int Quorum, double Hours, int PerDay); diff --git a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs index 8239d00..eacde97 100644 --- a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs @@ -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); } diff --git a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs index 8494952..89d594f 100644 --- a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs +++ b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs @@ -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) diff --git a/tests/ClawdDotNet.Core.Tests/Organisation/OrgTemplateTests.cs b/tests/ClawdDotNet.Core.Tests/Organisation/OrgTemplateTests.cs new file mode 100644 index 0000000..823b2af --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Organisation/OrgTemplateTests.cs @@ -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; + +/// +/// #691: A whole house as a file — never with credentials, and a broken file is refused before +/// it touches an instance. +/// +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("""{"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(() => OrgTemplates.Parse("""{"units":[]}""")); + Should.Throw(() => OrgTemplates.Parse("""{"format":"clawddotnet-org-template","formatVersion":99}""")); + Should.Throw(() => 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\""); + } +}