implements packer PR-07 build planning foundation

This commit is contained in:
bQUARKz 2026-03-11 18:01:30 +00:00
parent f03cbb28af
commit 14cff847ca
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
7 changed files with 465 additions and 0 deletions

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View File

@ -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;
}
}