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:
co-authored by
Claude Opus 5.5
parent
11465f3d74
commit
706b5cfd9e
@@ -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;
|
||||
|
||||
/// <summary>#691: What the human may change when importing a template — models and budgets fit one house, not every house.</summary>
|
||||
public sealed record TemplateOverrides(
|
||||
string? Model = null,
|
||||
IReadOnlyDictionary<string, string>? 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<string> Tools, string Identity, string Soul);
|
||||
|
||||
/// <summary>#691: What an import would do — and what stands in its way. Nothing is written for it.</summary>
|
||||
public sealed record TemplatePreview(
|
||||
string Name,
|
||||
string Version,
|
||||
string Fingerprint,
|
||||
IReadOnlyList<string> Problems,
|
||||
IReadOnlyList<string> Warnings,
|
||||
IReadOnlyList<TemplatePreviewUnit> Units,
|
||||
IReadOnlyList<TemplatePreviewAgent> Agents,
|
||||
IReadOnlyList<CredentialNeed> Credentials)
|
||||
{
|
||||
public bool CanImport => Problems.Count == 0;
|
||||
}
|
||||
|
||||
public sealed partial class OrgAdministration
|
||||
{
|
||||
// ─── templates (#691) ───
|
||||
|
||||
/// <summary>The organisation of this instance as a template file — never with credentials.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// What importing <paramref name="template"/> 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.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports the template: units with their rules and workspaces, agents with their texts,
|
||||
/// tools, models and budgets, the doctrines. Only with <paramref name="confirmed"/> — 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.
|
||||
/// </summary>
|
||||
public async Task<AdminResult> 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<UnitConfig>(JsonSerializer.Serialize(unit))!;
|
||||
copy.TemplateOrigin = origin;
|
||||
host.Instance.Units.Add(copy);
|
||||
host.Instance.Workspaces.Add(OwnWorkspace(copy.Id, copy.Name));
|
||||
}
|
||||
|
||||
var created = new List<string>();
|
||||
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<Dictionary<string, object?>>(t.Value.ToJsonString()) ?? new Dictionary<string, object?>(),
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>A short fingerprint of the template's content — which version of it came in.</summary>
|
||||
private static string Fingerprint(OrgTemplate template)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(template, OrgTemplates.Json))))[..12].ToLowerInvariant();
|
||||
}
|
||||
@@ -71,7 +71,7 @@ public sealed record StructurePreview(
|
||||
/// <para>Every structural change is written to the audit log with its reason — a change of
|
||||
/// structure is a change of rights (#205, #641).</para>
|
||||
/// </summary>
|
||||
public sealed class OrgAdministration(AppHost host, ILogger logger)
|
||||
public sealed partial class OrgAdministration(AppHost host, ILogger logger)
|
||||
{
|
||||
/// <summary>A value this far from the median of the others is an outlier.</summary>
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user