implements packer PR-07 build planning foundation
This commit is contained in:
parent
f03cbb28af
commit
14cff847ca
@ -0,0 +1,21 @@
|
||||
package p.packer.building;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public record PackerBuildPlan(
|
||||
String cacheKey,
|
||||
List<PackerPlannedAsset> assets,
|
||||
String assetTableJson,
|
||||
String preloadJson) {
|
||||
|
||||
public PackerBuildPlan {
|
||||
cacheKey = Objects.requireNonNull(cacheKey, "cacheKey").trim();
|
||||
assets = List.copyOf(Objects.requireNonNull(assets, "assets"));
|
||||
assetTableJson = Objects.requireNonNull(assetTableJson, "assetTableJson").trim();
|
||||
preloadJson = Objects.requireNonNull(preloadJson, "preloadJson").trim();
|
||||
if (cacheKey.isBlank() || assetTableJson.isBlank() || preloadJson.isBlank()) {
|
||||
throw new IllegalArgumentException("build plan fields must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package p.packer.building;
|
||||
|
||||
import p.packer.api.PackerOperationStatus;
|
||||
import p.packer.api.diagnostics.PackerDiagnostic;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public record PackerBuildPlanResult(
|
||||
PackerOperationStatus status,
|
||||
String summary,
|
||||
PackerBuildPlan plan,
|
||||
List<PackerDiagnostic> diagnostics) {
|
||||
|
||||
public PackerBuildPlanResult {
|
||||
Objects.requireNonNull(status, "status");
|
||||
summary = Objects.requireNonNull(summary, "summary").trim();
|
||||
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
|
||||
if (summary.isBlank()) {
|
||||
throw new IllegalArgumentException("summary must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,220 @@
|
||||
package p.packer.building;
|
||||
|
||||
import p.packer.api.PackerOperationStatus;
|
||||
import p.packer.api.PackerProjectContext;
|
||||
import p.packer.api.assets.PackerAssetState;
|
||||
import p.packer.api.diagnostics.PackerDiagnostic;
|
||||
import p.packer.api.diagnostics.PackerDiagnosticCategory;
|
||||
import p.packer.api.diagnostics.PackerDiagnosticSeverity;
|
||||
import p.packer.api.workspace.GetAssetDetailsRequest;
|
||||
import p.packer.api.workspace.ListAssetsRequest;
|
||||
import p.packer.declarations.PackerAssetDetailsService;
|
||||
import p.packer.workspace.FileSystemPackerWorkspaceService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class PackerBuildPlanner {
|
||||
private final FileSystemPackerWorkspaceService workspaceService;
|
||||
private final PackerAssetDetailsService detailsService;
|
||||
|
||||
public PackerBuildPlanner() {
|
||||
this(new FileSystemPackerWorkspaceService(), new PackerAssetDetailsService());
|
||||
}
|
||||
|
||||
public PackerBuildPlanner(
|
||||
FileSystemPackerWorkspaceService workspaceService,
|
||||
PackerAssetDetailsService detailsService) {
|
||||
this.workspaceService = Objects.requireNonNull(workspaceService, "workspaceService");
|
||||
this.detailsService = Objects.requireNonNull(detailsService, "detailsService");
|
||||
}
|
||||
|
||||
public PackerBuildPlanResult plan(PackerProjectContext project) {
|
||||
final var snapshot = workspaceService.listAssets(new ListAssetsRequest(Objects.requireNonNull(project, "project")));
|
||||
final List<PackerDiagnostic> diagnostics = new ArrayList<>(snapshot.diagnostics());
|
||||
final List<PackerPlannedAsset> plannedAssets = new ArrayList<>();
|
||||
|
||||
snapshot.assets().stream()
|
||||
.filter(asset -> asset.state() == PackerAssetState.MANAGED)
|
||||
.sorted(Comparator.comparingInt(asset -> asset.identity().assetId()))
|
||||
.forEach(asset -> {
|
||||
final var detailsResult = detailsService.getAssetDetails(new GetAssetDetailsRequest(project, Integer.toString(asset.identity().assetId())));
|
||||
diagnostics.addAll(detailsResult.diagnostics());
|
||||
if (detailsResult.details().summary().state() != PackerAssetState.MANAGED) {
|
||||
diagnostics.add(new PackerDiagnostic(
|
||||
PackerDiagnosticSeverity.ERROR,
|
||||
PackerDiagnosticCategory.BUILD,
|
||||
"Managed asset is not build-eligible: " + asset.identity().assetName(),
|
||||
asset.identity().assetRoot(),
|
||||
true));
|
||||
return;
|
||||
}
|
||||
|
||||
final List<PackerPlannedInput> plannedInputs = new ArrayList<>();
|
||||
detailsResult.details().inputsByRole().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(entry -> entry.getValue().forEach(input -> {
|
||||
if (!Files.isRegularFile(input)) {
|
||||
diagnostics.add(new PackerDiagnostic(
|
||||
PackerDiagnosticSeverity.ERROR,
|
||||
PackerDiagnosticCategory.BUILD,
|
||||
"Build input is missing: " + relativePath(project, input),
|
||||
input,
|
||||
true));
|
||||
return;
|
||||
}
|
||||
plannedInputs.add(new PackerPlannedInput(
|
||||
entry.getKey(),
|
||||
asset.identity().assetRoot().relativize(input).toString().replace('\\', '/'),
|
||||
fileHash(input),
|
||||
input));
|
||||
}));
|
||||
|
||||
if (diagnostics.stream().anyMatch(diagnostic ->
|
||||
diagnostic.blocking() && asset.identity().assetRoot().equals(parentRoot(diagnostic.evidencePath(), asset.identity().assetRoot())))) {
|
||||
return;
|
||||
}
|
||||
|
||||
plannedAssets.add(new PackerPlannedAsset(
|
||||
asset.identity().assetId(),
|
||||
asset.identity().assetUuid(),
|
||||
asset.identity().assetName(),
|
||||
asset.assetFamily(),
|
||||
relativePath(project, asset.identity().assetRoot()),
|
||||
detailsResult.details().outputFormat(),
|
||||
detailsResult.details().outputCodec(),
|
||||
asset.preloadEnabled(),
|
||||
plannedInputs,
|
||||
asset.identity().assetRoot()));
|
||||
});
|
||||
|
||||
final boolean hasBlocking = diagnostics.stream().anyMatch(PackerDiagnostic::blocking);
|
||||
if (hasBlocking) {
|
||||
return new PackerBuildPlanResult(
|
||||
PackerOperationStatus.FAILED,
|
||||
"Build plan blocked by diagnostics.",
|
||||
null,
|
||||
List.copyOf(diagnostics));
|
||||
}
|
||||
|
||||
final String assetTableJson = buildAssetTableJson(plannedAssets);
|
||||
final String preloadJson = buildPreloadJson(plannedAssets);
|
||||
final String cacheKey = sha256(PackerCanonicalJson.write(Map.of(
|
||||
"asset_table", parseCanonical(assetTableJson),
|
||||
"preload", parseCanonical(preloadJson),
|
||||
"inputs", plannedAssets.stream().map(this::cacheKeyView).toList())));
|
||||
return new PackerBuildPlanResult(
|
||||
diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL,
|
||||
"Build plan ready for " + plannedAssets.size() + " managed assets.",
|
||||
new PackerBuildPlan(cacheKey, plannedAssets, assetTableJson, preloadJson),
|
||||
List.copyOf(diagnostics));
|
||||
}
|
||||
|
||||
private Map<String, Object> cacheKeyView(PackerPlannedAsset asset) {
|
||||
return Map.of(
|
||||
"asset_family", asset.assetFamily(),
|
||||
"asset_id", asset.assetId(),
|
||||
"asset_name", asset.assetName(),
|
||||
"asset_uuid", asset.assetUuid(),
|
||||
"inputs", asset.inputs().stream().map(input -> Map.of(
|
||||
"content_hash", input.contentHash(),
|
||||
"path", input.relativePath(),
|
||||
"role", input.role())).toList(),
|
||||
"output_codec", asset.outputCodec(),
|
||||
"output_format", asset.outputFormat(),
|
||||
"preload", asset.preload(),
|
||||
"root", asset.relativeRoot());
|
||||
}
|
||||
|
||||
private String buildAssetTableJson(List<PackerPlannedAsset> assets) {
|
||||
final List<Map<String, Object>> rows = assets.stream()
|
||||
.map(asset -> {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("asset_family", asset.assetFamily());
|
||||
row.put("asset_id", asset.assetId());
|
||||
row.put("asset_name", asset.assetName());
|
||||
row.put("asset_uuid", asset.assetUuid());
|
||||
row.put("output_codec", asset.outputCodec());
|
||||
row.put("output_format", asset.outputFormat());
|
||||
row.put("relative_root", asset.relativeRoot());
|
||||
return row;
|
||||
})
|
||||
.toList();
|
||||
return PackerCanonicalJson.write(rows);
|
||||
}
|
||||
|
||||
private String buildPreloadJson(List<PackerPlannedAsset> assets) {
|
||||
final List<Map<String, Object>> rows = assets.stream()
|
||||
.filter(PackerPlannedAsset::preload)
|
||||
.map(asset -> {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("asset_id", asset.assetId());
|
||||
row.put("asset_name", asset.assetName());
|
||||
row.put("asset_uuid", asset.assetUuid());
|
||||
return row;
|
||||
})
|
||||
.toList();
|
||||
return PackerCanonicalJson.write(rows);
|
||||
}
|
||||
|
||||
private Object parseCanonical(String json) {
|
||||
try {
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, Object.class);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("Unable to parse canonical JSON", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Path parentRoot(Path evidencePath, Path fallbackRoot) {
|
||||
if (evidencePath == null) {
|
||||
return fallbackRoot;
|
||||
}
|
||||
Path current = evidencePath.toAbsolutePath().normalize();
|
||||
while (current != null && !current.equals(fallbackRoot)) {
|
||||
if (current.getParent() != null && current.getParent().equals(fallbackRoot)) {
|
||||
return fallbackRoot;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return fallbackRoot;
|
||||
}
|
||||
|
||||
private String relativePath(PackerProjectContext project, Path path) {
|
||||
final Path normalized = path.toAbsolutePath().normalize();
|
||||
final Path assetsRoot = project.rootPath().resolve("assets").toAbsolutePath().normalize();
|
||||
if (normalized.startsWith(assetsRoot)) {
|
||||
return assetsRoot.relativize(normalized).toString().replace('\\', '/');
|
||||
}
|
||||
return normalized.toString();
|
||||
}
|
||||
|
||||
private String fileHash(Path path) {
|
||||
try {
|
||||
return sha256(Files.readAllBytes(path));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("Unable to hash build input: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String sha256(String value) {
|
||||
return sha256(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private String sha256(byte[] value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is not available", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package p.packer.building;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
public final class PackerCanonicalJson {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
|
||||
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
||||
|
||||
private PackerCanonicalJson() {
|
||||
}
|
||||
|
||||
public static String write(Object value) {
|
||||
try {
|
||||
return MAPPER.writeValueAsString(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalArgumentException("Unable to serialize canonical JSON value", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package p.packer.building;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public record PackerPlannedAsset(
|
||||
int assetId,
|
||||
String assetUuid,
|
||||
String assetName,
|
||||
String assetFamily,
|
||||
String relativeRoot,
|
||||
String outputFormat,
|
||||
String outputCodec,
|
||||
boolean preload,
|
||||
List<PackerPlannedInput> inputs,
|
||||
Path assetRoot) {
|
||||
|
||||
public PackerPlannedAsset {
|
||||
assetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim();
|
||||
assetName = Objects.requireNonNull(assetName, "assetName").trim();
|
||||
assetFamily = Objects.requireNonNull(assetFamily, "assetFamily").trim();
|
||||
relativeRoot = Objects.requireNonNull(relativeRoot, "relativeRoot").trim();
|
||||
outputFormat = Objects.requireNonNull(outputFormat, "outputFormat").trim();
|
||||
outputCodec = Objects.requireNonNull(outputCodec, "outputCodec").trim();
|
||||
inputs = List.copyOf(Objects.requireNonNull(inputs, "inputs"));
|
||||
assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
|
||||
if (assetId <= 0 || assetUuid.isBlank() || assetName.isBlank() || relativeRoot.isBlank() || outputFormat.isBlank() || outputCodec.isBlank()) {
|
||||
throw new IllegalArgumentException("planned asset fields must be valid");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package p.packer.building;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
public record PackerPlannedInput(
|
||||
String role,
|
||||
String relativePath,
|
||||
String contentHash,
|
||||
Path absolutePath) {
|
||||
|
||||
public PackerPlannedInput {
|
||||
role = Objects.requireNonNull(role, "role").trim();
|
||||
relativePath = Objects.requireNonNull(relativePath, "relativePath").trim();
|
||||
contentHash = Objects.requireNonNull(contentHash, "contentHash").trim();
|
||||
absolutePath = Objects.requireNonNull(absolutePath, "absolutePath").toAbsolutePath().normalize();
|
||||
if (role.isBlank() || relativePath.isBlank() || contentHash.isBlank()) {
|
||||
throw new IllegalArgumentException("planned input fields must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
package p.packer.building;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import p.packer.api.PackerOperationStatus;
|
||||
import p.packer.api.PackerProjectContext;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class PackerBuildPlannerTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void equivalentProjectsProduceEquivalentBuildPlans() throws Exception {
|
||||
final Path left = createProject(tempDir.resolve("left"), false);
|
||||
final Path right = createProject(tempDir.resolve("right"), true);
|
||||
final PackerBuildPlanner planner = new PackerBuildPlanner();
|
||||
|
||||
final PackerBuildPlan leftPlan = planner.plan(new PackerProjectContext("left", left)).plan();
|
||||
final PackerBuildPlan rightPlan = planner.plan(new PackerProjectContext("right", right)).plan();
|
||||
|
||||
assertNotNull(leftPlan);
|
||||
assertNotNull(rightPlan);
|
||||
assertEquals(leftPlan.cacheKey(), rightPlan.cacheKey());
|
||||
assertEquals(leftPlan.assetTableJson(), rightPlan.assetTableJson());
|
||||
assertEquals(leftPlan.preloadJson(), rightPlan.preloadJson());
|
||||
}
|
||||
|
||||
@Test
|
||||
void assetOrderingIsDeterministicByAssetId() throws Exception {
|
||||
final Path projectRoot = createProject(tempDir.resolve("ordered"), true);
|
||||
final PackerBuildPlanner planner = new PackerBuildPlanner();
|
||||
|
||||
final PackerBuildPlan plan = planner.plan(new PackerProjectContext("ordered", projectRoot)).plan();
|
||||
|
||||
assertEquals(2, plan.assets().size());
|
||||
assertEquals(1, plan.assets().get(0).assetId());
|
||||
assertEquals(2, plan.assets().get(1).assetId());
|
||||
assertTrue(plan.assetTableJson().contains("\"asset_id\":1"));
|
||||
assertTrue(plan.assetTableJson().indexOf("\"asset_id\":1") < plan.assetTableJson().indexOf("\"asset_id\":2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingManagedInputsBlockBuildPlanning() throws Exception {
|
||||
final Path projectRoot = createProject(tempDir.resolve("broken"), false);
|
||||
Files.delete(projectRoot.resolve("assets/ui/atlas/sprites/confirm.png"));
|
||||
final PackerBuildPlanner planner = new PackerBuildPlanner();
|
||||
|
||||
final var result = planner.plan(new PackerProjectContext("broken", projectRoot));
|
||||
|
||||
assertEquals(PackerOperationStatus.FAILED, result.status());
|
||||
assertNull(result.plan());
|
||||
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Build input is missing")));
|
||||
}
|
||||
|
||||
private Path createProject(Path projectRoot, boolean reverseRegistryOrder) throws Exception {
|
||||
final Path atlasRoot = projectRoot.resolve("assets/ui/atlas");
|
||||
final Path soundsRoot = projectRoot.resolve("assets/audio/ui_sounds");
|
||||
Files.createDirectories(atlasRoot.resolve("sprites"));
|
||||
Files.createDirectories(soundsRoot.resolve("sources"));
|
||||
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
|
||||
Files.writeString(atlasRoot.resolve("asset.json"), """
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "ui_atlas",
|
||||
"type": "image_bank",
|
||||
"inputs": { "sprites": ["sprites/confirm.png"] },
|
||||
"output": { "format": "TILES/indexed_v1", "codec": "RAW" },
|
||||
"preload": { "enabled": true }
|
||||
}
|
||||
""");
|
||||
Files.writeString(soundsRoot.resolve("asset.json"), """
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "ui_sounds",
|
||||
"type": "sound_bank",
|
||||
"inputs": { "sources": ["sources/confirm.wav"] },
|
||||
"output": { "format": "SOUND/bank_v1", "codec": "RAW" },
|
||||
"preload": { "enabled": false }
|
||||
}
|
||||
""");
|
||||
Files.writeString(atlasRoot.resolve("sprites/confirm.png"), "png");
|
||||
Files.writeString(soundsRoot.resolve("sources/confirm.wav"), "wav");
|
||||
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), reverseRegistryOrder ? """
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_asset_id": 3,
|
||||
"assets": [
|
||||
{
|
||||
"asset_id": 2,
|
||||
"asset_uuid": "uuid-2",
|
||||
"root": "audio/ui_sounds"
|
||||
},
|
||||
{
|
||||
"asset_id": 1,
|
||||
"asset_uuid": "uuid-1",
|
||||
"root": "ui/atlas"
|
||||
}
|
||||
]
|
||||
}
|
||||
""" : """
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_asset_id": 3,
|
||||
"assets": [
|
||||
{
|
||||
"asset_id": 1,
|
||||
"asset_uuid": "uuid-1",
|
||||
"root": "ui/atlas"
|
||||
},
|
||||
{
|
||||
"asset_id": 2,
|
||||
"asset_uuid": "uuid-2",
|
||||
"root": "audio/ui_sounds"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
return projectRoot;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user