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);
|
||||
|
||||
Reference in New Issue
Block a user