Volver al índice

src/main/java/ar/com/companeros/tools/ToolRegistry.java

package ar.com.companeros.tools;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

/** Biblioteca certificada y propuestas de herramientas dentro de config/companeros/Tools. */
public final class ToolRegistry {
    private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
    private static final int MAX_PROPOSALS = 256;
    private static final int MAX_DEPENDENCY_DEPTH = 4;
    private final Path directory;
    private final Map<Key, ToolManifest> certified = new LinkedHashMap<>();
    private final Map<String, Proposal> pending = new LinkedHashMap<>();
    private final Map<String, PrimitiveDefinition> primitives = new LinkedHashMap<>();
    private final Map<Key, Statistics> statistics = new HashMap<>();
    private boolean statisticsDirty;

    public ToolRegistry(Path toolsDirectory) throws IOException {
        directory = toolsDirectory.toAbsolutePath().normalize();
        if (Files.isSymbolicLink(directory)) throw new IOException("Tools no puede ser un enlace simbólico");
        Files.createDirectories(directory);
        installMinecraftPrimitives();
        loadStatistics();
    }

    // El registro de capacidades pertenece al código del mod, nunca a los archivos propuestos.
    public synchronized void registerPrimitive(String name, ToolPermission permission, Set<String> allowedArguments) {
        ToolManifest.identifier(name);
        for (String argument : allowedArguments) ToolManifest.argumentName(argument);
        if (primitives.containsKey(name)) throw ToolManifest.invalid("Primitiva ya registrada");
        primitives.put(name, new PrimitiveDefinition(java.util.Objects.requireNonNull(permission), Set.copyOf(allowedArguments)));
    }

    public synchronized void registerCertified(ToolManifest manifest) {
        Key key = new Key(manifest.id(), manifest.version());
        ToolManifest existing = certified.get(key);
        if (existing != null) {
            if (!existing.sha256().equals(manifest.sha256())) throw ToolManifest.invalid("Una versión certificada es inmutable");
            return;
        }
        validateDefinition(manifest, new HashSet<>(), 0, new int[] { 0 });
        certified.put(key, manifest);
    }

    /** Solo un controlador confiable llama esta promoción después de probar el hash exacto. */
    public synchronized ToolManifest certify(String proposalHash, UUID trustedReviewer, TestEvidence evidence) {
        Proposal proposal = pending.get(proposalHash);
        if (proposal == null) throw ToolManifest.invalid("Propuesta desconocida");
        if (trustedReviewer == null || evidence == null || !evidence.passed()
            || !proposalHash.equals(evidence.manifestHash()) || evidence.suite().isBlank()
            || evidence.suite().length() > 160 || evidence.completedAt() == null
            || evidence.completedAt().isAfter(Instant.now().plusSeconds(60)))
            throw ToolManifest.invalid("La certificación requiere evidencia aprobada del hash exacto");
        registerCertified(proposal.manifest());
        pending.remove(proposalHash);
        return proposal.manifest();
    }

    // Los nombres de archivo se construyen con identificadores validados; el JSON no elige rutas.
    public synchronized Proposal propose(UUID trustedAuthor, String source) throws IOException {
        if (pending.size() >= MAX_PROPOSALS) throw ToolManifest.invalid("Límite de propuestas pendientes alcanzado");
        ToolManifest manifest = ToolManifest.parse(source, trustedAuthor);
        validateDefinition(manifest, new HashSet<>(), 0, new int[] { 0 });
        Proposal old = pending.get(manifest.sha256());
        if (old != null) return old;
        Proposal proposal = new Proposal(manifest, Instant.now());
        JsonObject envelope = new JsonObject();
        envelope.addProperty("status", "pending");
        envelope.addProperty("author", trustedAuthor.toString());
        envelope.addProperty("createdAt", proposal.createdAt().toString());
        envelope.addProperty("sha256", manifest.sha256());
        envelope.add("manifest", manifest.toJson());
        String name = "pending_" + manifest.id() + "_v" + manifest.version() + "_" + manifest.sha256().substring(0, 16) + ".json";
        writeAtomically(directory.resolve(name), JSON.toJson(envelope));
        pending.put(manifest.sha256(), proposal);
        return proposal;
    }

    /** Recupera candidatos. Ningún archivo se convierte automáticamente en herramienta ejecutable. */
    public synchronized LoadReport loadProposals() throws IOException {
        List<String> rejected = new ArrayList<>();
        int loaded = 0;
        List<Path> files;
        try (var paths = Files.list(directory)) {
            files = paths.filter(path -> path.getFileName().toString().startsWith("pending_")
                && path.getFileName().toString().endsWith(".json")).sorted().limit(MAX_PROPOSALS + 1L).toList();
        }
        if (files.size() > MAX_PROPOSALS) rejected.add("Cantidad de propuestas superior al límite");
        for (Path file : files.stream().limit(MAX_PROPOSALS).toList()) {
            try {
                if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > ToolManifest.MAX_SOURCE_BYTES + 4096L)
                    throw ToolManifest.invalid("Archivo inválido o demasiado grande");
                JsonElement parsed = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8));
                if (!parsed.isJsonObject()) throw ToolManifest.invalid("Sobre inválido");
                JsonObject envelope = parsed.getAsJsonObject();
                ToolManifest.rejectUnknown(envelope, Set.of("status", "author", "createdAt", "sha256", "manifest"));
                if (!"pending".equals(envelope.get("status").getAsString())) throw ToolManifest.invalid("El archivo no puede conceder aprobación");
                UUID author = UUID.fromString(envelope.get("author").getAsString());
                ToolManifest manifest = ToolManifest.parse(envelope.get("manifest").toString(), author);
                if (!manifest.sha256().equals(envelope.get("sha256").getAsString())) throw ToolManifest.invalid("El contenido no coincide con su hash");
                ToolManifest approved = certified.get(new Key(manifest.id(), manifest.version()));
                if (approved != null) {
                    if (!approved.sha256().equals(manifest.sha256())) throw ToolManifest.invalid("Versión certificada adulterada");
                    continue; // Un sobre pendiente antiguo no revoca una certificación comprobada.
                }
                validateDefinition(manifest, new HashSet<>(), 0, new int[] { 0 });
                if (pending.size() >= MAX_PROPOSALS && !pending.containsKey(manifest.sha256()))
                    throw ToolManifest.invalid("Límite de propuestas alcanzado");
                pending.put(manifest.sha256(), new Proposal(manifest, Instant.parse(envelope.get("createdAt").getAsString())));
                loaded++;
            } catch (RuntimeException exception) { rejected.add(file.getFileName() + ": " + safeMessage(exception)); }
            catch (StackOverflowError error) { rejected.add(file.getFileName() + ": JSON demasiado profundo"); }
        }
        return new LoadReport(loaded, List.copyOf(rejected));
    }

    // Preparación: permisos, dependencias y argumentos se comprueban antes de iniciar acciones.
    public synchronized ToolExecution start(String id, int version, ExecutionContext context, PrimitiveExecutor executor) {
        ToolManifest manifest = certified.get(new Key(ToolManifest.identifier(id), version));
        if (manifest == null) throw ToolManifest.invalid("Herramienta no certificada");
        return prepare(manifest, context, executor, status -> recordResult(new Key(manifest.id(), manifest.version()), status));
    }

    // Sólo el servicio local de aprendizaje abre pruebas pendientes; el JSON no concede este permiso.
    synchronized ToolExecution startTrial(ToolManifest manifest, ExecutionContext context, PrimitiveExecutor executor,
                                          java.util.function.Consumer<ToolExecution.Status> completed) {
        Proposal proposal = pending.get(manifest.sha256());
        if (proposal == null || !proposal.manifest().author().equals(context.actorId()))
            throw ToolManifest.invalid("La prueba pertenece al autor de una propuesta pendiente");
        return prepare(manifest, context, executor, status -> {
            recordResult(new Key(manifest.id(), manifest.version()), status);
            completed.accept(status);
        });
    }

    synchronized void validateInputs(ToolManifest manifest, ExecutionContext context) {
        compile(manifest, context.inputs(), context.permissions(), new ArrayList<>(), new ArrayList<>(), 0, manifest.maxTicks());
    }

    Path directory() { return directory; }

    private ToolExecution prepare(ToolManifest manifest, ExecutionContext context, PrimitiveExecutor executor,
                                  java.util.function.Consumer<ToolExecution.Status> completed) {
        if (context == null || executor == null) throw ToolManifest.invalid("Falta el contexto de ejecución");
        if (!context.permissions().containsAll(manifest.permissions())) throw ToolManifest.invalid("Permisos insuficientes");
        List<CompiledStep> steps = new ArrayList<>();
        List<ExecutionBudget> budgets = new ArrayList<>();
        compile(manifest, context.inputs(), context.permissions(), steps, budgets, 0, manifest.maxTicks());
        if (steps.size() > manifest.maxSteps()) throw ToolManifest.invalid("La composición excede su límite de pasos");
        return new ToolExecution(manifest, context, List.copyOf(steps), List.copyOf(budgets), executor,
            completed);
    }

    public synchronized List<ToolManifest> certifiedTools() { return List.copyOf(certified.values()); }
    public synchronized List<Proposal> pendingProposals() { return List.copyOf(pending.values()); }
    public synchronized Statistics statistics(String id, int version) {
        return statistics.getOrDefault(new Key(id, version), new Statistics(0, 0, 0, ""));
    }

    public synchronized void saveStatistics() throws IOException {
        if (!statisticsDirty) return;
        JsonObject document = new JsonObject();
        document.addProperty("schema", 1);
        JsonObject entries = new JsonObject();
        for (var entry : statistics.entrySet()) {
            JsonObject value = new JsonObject();
            value.addProperty("successes", entry.getValue().successes());
            value.addProperty("failures", entry.getValue().failures());
            value.addProperty("cancellations", entry.getValue().cancellations());
            value.addProperty("lastResult", entry.getValue().lastResult());
            entries.add(entry.getKey().id() + ":" + entry.getKey().version(), value);
        }
        document.add("tools", entries);
        writeAtomically(directory.resolve("statistics.json"), JSON.toJson(document));
        statisticsDirty = false;
    }

    private synchronized void recordResult(Key key, ToolExecution.Status status) {
        Statistics old = statistics.getOrDefault(key, new Statistics(0, 0, 0, ""));
        statistics.put(key, new Statistics(increment(old.successes(), status == ToolExecution.Status.SUCCEEDED),
            increment(old.failures(), status == ToolExecution.Status.FAILED),
            increment(old.cancellations(), status == ToolExecution.Status.CANCELLED), Instant.now().toString()));
        statisticsDirty = true;
    }

    private void validateDefinition(ToolManifest manifest, Set<Key> ancestors, int depth, int[] visits) {
        if (depth > MAX_DEPENDENCY_DEPTH) throw ToolManifest.invalid("Dependencias demasiado profundas");
        if (++visits[0] > 512) throw ToolManifest.invalid("Grafo de dependencias demasiado grande");
        Key key = new Key(manifest.id(), manifest.version());
        if (!ancestors.add(key)) throw ToolManifest.invalid("Dependencia circular");
        try {
            for (ToolManifest.Dependency dependency : manifest.dependencies()) {
                ToolManifest child = exactDependency(dependency);
                if (!manifest.permissions().containsAll(child.permissions())) throw ToolManifest.invalid("Faltan permisos de una dependencia");
                validateDefinition(child, ancestors, depth + 1, visits);
            }
            for (ToolManifest.Step step : manifest.steps()) {
                if (step.primitive() == null) continue;
                PrimitiveDefinition definition = primitives.get(step.primitive());
                if (definition == null) throw ToolManifest.invalid("Primitiva no permitida: " + step.primitive());
                if (!manifest.permissions().contains(definition.permission())) throw ToolManifest.invalid("Falta un permiso declarado");
                validatePrimitiveArguments(step.primitive(), step.arguments(), definition, true);
            }
        } finally { ancestors.remove(key); }
    }

    private ToolManifest exactDependency(ToolManifest.Dependency dependency) {
        ToolManifest child = certified.get(new Key(dependency.id(), dependency.version()));
        if (child == null || !child.sha256().equals(dependency.sha256())) throw ToolManifest.invalid("Dependencia no certificada o hash diferente");
        return child;
    }

    private void compile(ToolManifest manifest, JsonObject inputs, Set<ToolPermission> permissions,
                         List<CompiledStep> result, List<ExecutionBudget> budgets, int depth, int maximumTicks) {
        if (depth > MAX_DEPENDENCY_DEPTH) throw ToolManifest.invalid("Composición demasiado profunda");
        if (!permissions.containsAll(manifest.permissions())) throw ToolManifest.invalid("Permisos insuficientes para una dependencia");
        int start = result.size();
        for (ToolManifest.Step step : manifest.steps()) {
            JsonObject args = ToolManifest.resolve(step.arguments(), inputs);
            if (step.primitive() != null) {
                PrimitiveDefinition definition = primitives.get(step.primitive());
                validatePrimitiveArguments(step.primitive(), args, definition, false);
                result.add(new CompiledStep(step.primitive(), args, step.timeoutTicks()));
            } else {
                ToolManifest child = exactDependency(step.tool());
                compile(child, args, permissions, result, budgets, depth + 1, Math.min(step.timeoutTicks(), child.maxTicks()));
            }
            if (result.size() > ToolManifest.MAX_STEPS) throw ToolManifest.invalid("Demasiados pasos expandidos");
        }
        if (result.size() - start > manifest.maxSteps()) throw ToolManifest.invalid("La dependencia excede su límite de pasos");
        budgets.add(new ExecutionBudget(start, result.size(), Math.min(maximumTicks, manifest.maxTicks())));
    }

    private void installMinecraftPrimitives() {
        registerPrimitive("move_to", ToolPermission.MOVE, Set.of("x", "y", "z"));
        registerPrimitive("mine_block", ToolPermission.MINE, Set.of("x", "y", "z"));
        registerPrimitive("place_block", ToolPermission.PLACE, Set.of("x", "y", "z", "item"));
        registerPrimitive("craft", ToolPermission.CRAFT, Set.of("item", "count"));
        registerPrimitive("eat", ToolPermission.EAT, Set.of());
        registerPrimitive("attack", ToolPermission.COMBAT, Set.of("target"));
        registerPrimitive("collect", ToolPermission.INVENTORY, Set.of("radius"));
        registerPrimitive("store", ToolPermission.INVENTORY, Set.of("x", "y", "z", "item", "count"));
        registerPrimitive("inspect", ToolPermission.WORLD_READ, Set.of());
        registerPrimitive("research_recipe", ToolPermission.WORLD_READ, Set.of("item"));
        registerPrimitive("research_web", ToolPermission.WORLD_READ, Set.of("query"));
        registerPrimitive("choose_skin", ToolPermission.WORLD_READ, Set.of("profile"));
        registerPrimitive("parasite_upgrade_propose", ToolPermission.WORLD_READ, Set.of("capacity", "goal"));
        registerPrimitive("parasite_upgrade_status", ToolPermission.WORLD_READ, Set.of());
        registerPrimitive("parasite_upgrade_catalog", ToolPermission.WORLD_READ, Set.of("query"));
        registerPrimitive("parasite_upgrade_apply", ToolPermission.PARASITE_UPGRADE, Set.of("quote"));
        registerPrimitive("parasite_appearance", ToolPermission.PARASITE_UPGRADE, Set.of("prefab", "slot"));
        registerPrimitive("parasite_appearance_catalog", ToolPermission.WORLD_READ, Set.of());
        registerPrimitive("propose_tool", ToolPermission.WORLD_READ, Set.of("manifest"));
        registerPrimitive("suggest_change", ToolPermission.SUGGESTION_WRITE, Set.of("manifest"));
        registerPrimitive("sneak", ToolPermission.MOVE, Set.of("enabled"));
        registerPrimitive("create_banner", ToolPermission.CRAFT, Set.of("baseColor", "circleColor", "symbol", "symbolColor"));
        registerPrimitive("place_banner", ToolPermission.PLACE, Set.of("x", "y", "z"));
        registerPrimitive("message_internal", ToolPermission.INTERNAL_CHAT, Set.of("recipient", "text"));
        registerPrimitive("message_global", ToolPermission.GLOBAL_CHAT, Set.of("text"));
    }

    private static void validatePrimitiveArguments(String primitive, JsonObject args, PrimitiveDefinition definition, boolean referencesAllowed) {
        if (definition == null) throw ToolManifest.invalid("Primitiva desconocida");
        ToolManifest.rejectUnknown(args, definition.arguments());
        ToolManifest.validateArguments(args, referencesAllowed);
        Set<String> required = switch (primitive) {
            case "move_to", "mine_block", "place_banner" -> Set.of("x", "y", "z");
            case "place_block", "store" -> Set.of("x", "y", "z", "item");
            case "craft", "research_recipe" -> Set.of("item");
            case "research_web" -> Set.of("query");
            case "choose_skin" -> Set.of("profile");
            case "parasite_upgrade_propose" -> Set.of("capacity", "goal");
            case "parasite_upgrade_catalog" -> Set.of("query");
            case "parasite_upgrade_apply" -> Set.of("quote");
            case "parasite_appearance" -> Set.of("prefab", "slot");
            case "propose_tool", "suggest_change" -> Set.of("manifest");
            case "sneak" -> Set.of("enabled");
            case "create_banner" -> Set.of("baseColor", "circleColor", "symbol", "symbolColor");
            case "attack" -> Set.of("target");
            case "message_internal" -> Set.of("recipient", "text");
            case "message_global" -> Set.of("text");
            default -> Set.of();
        };
        if (!args.keySet().containsAll(required)) throw ToolManifest.invalid("Faltan argumentos de la primitiva " + primitive);
        for (var entry : args.entrySet()) {
            JsonElement value = entry.getValue();
            if (referencesAllowed && value.isJsonObject()) continue;
            switch (entry.getKey()) {
                case "x", "y", "z", "count", "radius" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) throw ToolManifest.invalid("Se esperaba un número");
                    double number = value.getAsDouble();
                    if (!Double.isFinite(number) || Math.abs(number) > 30_000_000) throw ToolManifest.invalid("Coordenada fuera del límite");
                    if (entry.getKey().equals("count") && (number != Math.floor(number) || number < 1 || number > 64))
                        throw ToolManifest.invalid("Cantidad fuera del límite");
                    if (entry.getKey().equals("radius") && (number <= 0 || number > 4)) throw ToolManifest.invalid("Alcance de recogida fuera del límite");
                    if (!primitive.equals("move_to") && Set.of("x", "y", "z").contains(entry.getKey()) && number != Math.floor(number))
                        throw ToolManifest.invalid("La posición del bloque debe usar enteros");
                }
                case "item" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()
                        || !value.getAsString().matches("[a-z0-9_.-]+:[a-z0-9_./-]+")) throw ToolManifest.invalid("Identificador de item inválido");
                }
                case "profile" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw ToolManifest.invalid("Cuenta de skin inválida");
                    ar.com.companeros.identity.SkinSources.profile(value.getAsString());
                }
                case "capacity" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw ToolManifest.invalid("Capacidad inválida");
                    try { ar.com.companeros.horror.ParasiteUpgrades.capacity(value.getAsString()); }
                    catch (IllegalArgumentException failure) { throw ToolManifest.invalid("Capacidad inválida"); }
                }
                case "goal" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()
                            || value.getAsString().length() > 220 || value.getAsString().contains("\n")) throw ToolManifest.invalid("Objetivo inválido");
                }
                case "quote" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || !canonicalUuid(value.getAsString()))
                        throw ToolManifest.invalid("Cotización inválida");
                }
                case "target" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw ToolManifest.invalid("Identidad inválida");
                    try { UUID.fromString(value.getAsString()); }
                    catch (IllegalArgumentException exception) { throw ToolManifest.invalid("Identidad inválida"); }
                }
                case "recipient" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw ToolManifest.invalid("Destinatario inválido");
                    String recipient = value.getAsString();
                    if (!recipient.equalsIgnoreCase("all") && !recipient.matches("[A-Za-z0-9_]{1,16}") && !canonicalUuid(recipient))
                        throw ToolManifest.invalid("El destinatario debe ser all, un nombre o UUID");
                }
                case "text", "query" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank())
                        throw ToolManifest.invalid("Mensaje vacío o inválido");
                    if (entry.getKey().equals("query") && (value.getAsString().length() > 220 || value.getAsString().contains("\n")))
                        throw ToolManifest.invalid("Consulta web fuera de límite");
                }
                case "manifest" -> {
                    if (!Set.of("propose_tool", "suggest_change").contains(primitive) || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()
                        || value.getAsString().isBlank() || value.getAsString().getBytes(StandardCharsets.UTF_8).length > 4096)
                        throw ToolManifest.invalid("Propuesta de herramienta fuera del límite");
                }
                case "enabled" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) throw ToolManifest.invalid("Se requiere true o false");
                }
                case "baseColor", "circleColor", "symbolColor" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()
                        || java.util.Arrays.stream(net.minecraft.world.item.DyeColor.values())
                            .noneMatch(color -> color.getName().equals(value.getAsString())))
                        throw ToolManifest.invalid("Color de bandera desconocido");
                }
                case "symbol" -> {
                    if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString())
                        throw ToolManifest.invalid("Símbolo de bandera inválido");
                    ToolManifest.identifier(value.getAsString());
                }
                default -> { } // Una extensión confiable puede definir otros argumentos escalares.
            }
        }
    }

    private static boolean canonicalUuid(String value) {
        try { return UUID.fromString(value).toString().equalsIgnoreCase(value); }
        catch (IllegalArgumentException exception) { return false; }
    }

    // Estadísticas persistentes: escritura explícita en checkpoint, nunca en cada tick.
    private void loadStatistics() throws IOException {
        Path file = directory.resolve("statistics.json");
        if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return;
        if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > 1_048_576)
            throw new IOException("Archivo de estadísticas inválido");
        try {
            JsonObject root = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
            if (root.get("schema").getAsInt() != 1) throw ToolManifest.invalid("Estadísticas de versión desconocida");
            JsonObject entries = root.getAsJsonObject("tools");
            if (entries.size() > 4096) throw ToolManifest.invalid("Demasiadas estadísticas");
            for (var entry : entries.entrySet()) {
                String[] parts = entry.getKey().split(":", -1);
                if (parts.length != 2) throw ToolManifest.invalid("Clave de estadística inválida");
                int version = Integer.parseInt(parts[1]);
                if (version < 1 || version > 1_000_000) throw ToolManifest.invalid("Versión inválida");
                JsonObject value = entry.getValue().getAsJsonObject();
                statistics.put(new Key(ToolManifest.identifier(parts[0]), version), new Statistics(
                    nonnegative(value, "successes"), nonnegative(value, "failures"), nonnegative(value, "cancellations"),
                    value.get("lastResult").getAsString()));
            }
        } catch (RuntimeException exception) { throw new IOException("Estadísticas dañadas", exception); }
    }

    private static long nonnegative(JsonObject value, String key) {
        long number = value.get(key).getAsBigDecimal().longValueExact();
        if (number < 0) throw ToolManifest.invalid("Estadística negativa");
        return number;
    }

    private static long increment(long value, boolean increment) {
        return increment && value < Long.MAX_VALUE ? value + 1 : value;
    }

    private void writeAtomically(Path target, String source) throws IOException {
        Path checked = target.toAbsolutePath().normalize();
        if (!checked.getParent().equals(directory) || Files.isSymbolicLink(checked)) throw new IOException("Ruta fuera de Tools");
        Path temporary = directory.resolve("tool_write_" + UUID.randomUUID() + ".tmp");
        try {
            Files.writeString(temporary, source, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
            try { Files.move(temporary, checked, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
            catch (java.nio.file.AtomicMoveNotSupportedException exception) { Files.move(temporary, checked, StandardCopyOption.REPLACE_EXISTING); }
        } finally { Files.deleteIfExists(temporary); }
    }

    private static String safeMessage(RuntimeException exception) {
        String message = exception.getMessage();
        return message == null ? "Formato inválido" : message.substring(0, Math.min(message.length(), 256));
    }

    private record Key(String id, int version) { }
    private record PrimitiveDefinition(ToolPermission permission, Set<String> arguments) { }
    public record Proposal(ToolManifest manifest, Instant createdAt) { }
    public record LoadReport(int loaded, List<String> rejected) { }
    public record TestEvidence(String manifestHash, String suite, boolean passed, Instant completedAt) { }
    public record Statistics(long successes, long failures, long cancellations, String lastResult) {
        public double successRate() { double tested = (double) successes + failures; return tested == 0 ? 0 : successes / tested; }
    }

    public record ExecutionContext(UUID actorId, long startTick, Set<ToolPermission> permissions, JsonObject inputs) {
        public ExecutionContext {
            if (actorId == null || startTick < 0 || permissions == null || inputs == null) throw ToolManifest.invalid("Contexto inválido");
            permissions = Set.copyOf(permissions);
            inputs = inputs.deepCopy();
            ToolManifest.validateArguments(inputs, false);
        }
        @Override public JsonObject inputs() { return inputs.deepCopy(); }
    }

    static record CompiledStep(String primitive, JsonObject arguments, int timeoutTicks) { }
    static record ExecutionBudget(int startStep, int endStep, int maxTicks) { }
}