implements PR-10f bank composition apply through packer and snapshot refresh

This commit is contained in:
bQUARKz 2026-03-19 00:56:46 +00:00
parent 401f27a5bb
commit 2fa604e308
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
11 changed files with 288 additions and 17 deletions

View File

@ -20,4 +20,6 @@ public interface PackerWorkspaceService {
DeleteAssetResult deleteAsset(DeleteAssetRequest request);
UpdateAssetContractResponse updateAssetContract(UpdateAssetContractRequest request);
ApplyBankCompositionResponse applyBankComposition(ApplyBankCompositionRequest request);
}

View File

@ -0,0 +1,16 @@
package p.packer.messages;
import java.util.List;
import java.util.Objects;
public record ApplyBankCompositionRequest(
PackerProjectContext project,
AssetReference assetReference,
List<String> selectedFiles) {
public ApplyBankCompositionRequest {
Objects.requireNonNull(project, "project");
Objects.requireNonNull(assetReference, "assetReference");
selectedFiles = List.copyOf(Objects.requireNonNull(selectedFiles, "selectedFiles"));
}
}

View File

@ -0,0 +1,6 @@
package p.packer.messages;
public record ApplyBankCompositionResponse(
boolean success,
String errorMessage) {
}

View File

@ -0,0 +1,18 @@
package p.packer.models;
import java.util.Objects;
public record PackerAssetArtifactSelection(
String file,
int index) {
public PackerAssetArtifactSelection {
file = Objects.requireNonNull(file, "file").trim();
if (file.isBlank()) {
throw new IllegalArgumentException("file must not be blank");
}
if (index < 0) {
throw new IllegalArgumentException("index must be non-negative");
}
}
}

View File

@ -15,6 +15,7 @@ public record PackerAssetDeclaration(
String name,
AssetFamilyCatalog assetFamily,
Map<String, List<String>> inputsByRole,
List<PackerAssetArtifactSelection> artifacts,
OutputFormatCatalog outputFormat,
OutputCodecCatalog outputCodec,
Map<String, String> outputMetadata,
@ -28,6 +29,7 @@ public record PackerAssetDeclaration(
name = Objects.requireNonNull(name, "name").trim();
assetFamily = Objects.requireNonNull(assetFamily, "assetFamily");
inputsByRole = Map.copyOf(Objects.requireNonNull(inputsByRole, "inputsByRole"));
artifacts = List.copyOf(Objects.requireNonNull(artifacts, "artifacts"));
outputFormat = Objects.requireNonNull(outputFormat, "outputFormat");
outputCodec = Objects.requireNonNull(outputCodec, "outputCodec");
outputMetadata = Map.copyOf(Objects.requireNonNull(outputMetadata, "outputMetadata"));

View File

@ -599,6 +599,13 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return writeCoordinator.execute(project, () -> updateAssetContractInWriteLane(safeRequest));
}
@Override
public ApplyBankCompositionResponse applyBankComposition(final ApplyBankCompositionRequest request) {
final ApplyBankCompositionRequest safeRequest = Objects.requireNonNull(request, "request");
final PackerProjectContext project = safeRequest.project();
return writeCoordinator.execute(project, () -> applyBankCompositionInWriteLane(safeRequest));
}
private UpdateAssetContractResponse updateAssetContractInWriteLane(UpdateAssetContractRequest request) {
final PackerProjectContext project = request.project();
workspaceFoundation.initWorkspace(new InitWorkspaceRequest(project));
@ -656,6 +663,52 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
}
}
private ApplyBankCompositionResponse applyBankCompositionInWriteLane(ApplyBankCompositionRequest request) {
final PackerProjectContext project = request.project();
workspaceFoundation.initWorkspace(new InitWorkspaceRequest(project));
final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot();
final PackerDeleteAssetEvaluation evaluation = actionReadService.evaluateDelete(snapshot, project, request.assetReference());
if (!evaluation.canDelete()) {
return new ApplyBankCompositionResponse(
false,
Objects.requireNonNullElse(evaluation.reason(), "Asset bank composition cannot be updated."));
}
final Path assetRoot = evaluation.resolved().assetRoot();
final Path manifestPath = assetRoot.resolve("asset.json");
if (!Files.isRegularFile(manifestPath)) {
return new ApplyBankCompositionResponse(false, "asset.json was not found for the requested asset root.");
}
final ObjectNode manifest;
try {
final JsonNode rawManifest = mapper.readTree(manifestPath.toFile());
if (!(rawManifest instanceof ObjectNode objectNode)) {
return new ApplyBankCompositionResponse(false, "asset.json must contain a JSON object at the root.");
}
manifest = objectNode;
} catch (IOException exception) {
return new ApplyBankCompositionResponse(false, "Unable to read asset manifest: " + exception.getMessage());
}
try {
patchManifestArtifacts(manifest, request);
mapper.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
final var runtime = runtimeRegistry.update(project, (currentSnapshot, generation) -> runtimePatchService.afterUpdateAssetContract(
currentSnapshot,
generation,
assetRoot,
manifestPath,
evaluation.resolved().registryEntry()));
saveRuntimeCache(project, runtime.snapshot());
return new ApplyBankCompositionResponse(true, null);
} catch (IOException exception) {
return new ApplyBankCompositionResponse(false, "Unable to update asset bank composition: " + exception.getMessage());
} catch (RuntimeException exception) {
return new ApplyBankCompositionResponse(false, "Unable to update runtime snapshot: " + exception.getMessage());
}
}
private OutputFormatCatalog resolveManifestOutputFormat(ObjectNode manifest) {
final JsonNode outputNode = manifest.get("output");
if (!(outputNode instanceof ObjectNode outputObject)) {
@ -701,6 +754,27 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
});
}
private void patchManifestArtifacts(ObjectNode manifest, ApplyBankCompositionRequest request) {
final ObjectNode inputsNode = mutableObject(manifest, "inputs");
inputsNode.removeAll();
final var artifactsNode = manifest.putArray("artifacts");
for (int index = 0; index < request.selectedFiles().size(); index += 1) {
final String file = Objects.requireNonNullElse(request.selectedFiles().get(index), "").trim();
if (file.isBlank() || !isTrustedRelativePath(file)) {
throw new IllegalArgumentException("Selected artifact file paths must stay inside the asset root.");
}
final var artifactNode = artifactsNode.addObject();
artifactNode.put("file", file);
artifactNode.put("index", index);
}
}
private boolean isTrustedRelativePath(String value) {
final Path path = Path.of(value).normalize();
return !path.isAbsolute() && !path.startsWith("..");
}
private ObjectNode mutableObject(ObjectNode parent, String fieldName) {
final JsonNode current = parent.get(fieldName);
if (current instanceof ObjectNode objectNode) {

View File

@ -7,6 +7,7 @@ import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog;
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.packer.models.PackerAssetArtifactSelection;
import p.packer.models.PackerAssetDeclaration;
import p.packer.models.PackerAssetDeclarationParseResult;
import p.packer.models.PackerDiagnostic;
@ -45,6 +46,7 @@ public final class PackerAssetDeclarationParser {
final var name = requiredText(root, "name", diagnostics, manifestPath);
final var assetFamily = requiredAssetFamily(root, diagnostics, manifestPath);
final var inputsByRole = requiredInputs(root.path("inputs"), diagnostics, manifestPath);
final var artifacts = optionalArtifacts(root.path("artifacts"), diagnostics, manifestPath);
final var outputFormat = requiredOutputFormat(root.path("output"), diagnostics, manifestPath);
final var outputCodec = requiredOutputCodec(root.path("output"), diagnostics, manifestPath);
final var outputMetadata = optionalOutputMetadata(root.path("output"), diagnostics, manifestPath);
@ -70,6 +72,7 @@ public final class PackerAssetDeclarationParser {
name,
assetFamily,
inputsByRole,
artifacts,
outputFormat,
outputCodec,
outputMetadata,
@ -274,6 +277,61 @@ public final class PackerAssetDeclarationParser {
return Map.copyOf(result);
}
private List<PackerAssetArtifactSelection> optionalArtifacts(
final JsonNode node,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
if (node.isMissingNode() || node.isNull()) {
return List.of();
}
if (!node.isArray()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'artifacts' must be an array.",
manifestPath,
true));
return List.of();
}
final List<PackerAssetArtifactSelection> result = new ArrayList<>();
for (JsonNode entry : node) {
if (!entry.isObject()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Each artifact entry must be an object with 'file' and 'index'.",
manifestPath,
true));
continue;
}
final JsonNode fileNode = entry.path("file");
final JsonNode indexNode = entry.path("index");
if (!fileNode.isTextual() || fileNode.asText().isBlank() || !indexNode.isInt() || indexNode.asInt() < 0) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Each artifact entry must define a non-blank 'file' and a non-negative integer 'index'.",
manifestPath,
true));
continue;
}
final String relativePath = fileNode.asText().trim();
if (!isTrustedRelativePath(relativePath)) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Artifact file path must stay inside the asset root.",
manifestPath,
true));
continue;
}
result.add(new PackerAssetArtifactSelection(relativePath, indexNode.asInt()));
}
result.sort(Comparator.comparingInt(PackerAssetArtifactSelection::index));
return List.copyOf(result);
}
private boolean isTrustedRelativePath(final String value) {
final Path path = Path.of(value).normalize();
return !path.isAbsolute() && !path.startsWith("..");

View File

@ -150,7 +150,7 @@ public final class PackerAssetDetailsService {
.map(this::toBankCompositionFile)
.toList();
final List<PackerBankCompositionFile> selectedFiles = flattenSelectedInputPaths(declaration.inputsByRole()).stream()
final List<PackerBankCompositionFile> selectedFiles = selectedPathsFor(declaration).stream()
.map(path -> resolveSelectedBankFile(runtimeAsset.assetRoot(), path, walkFilesByPath))
.flatMap(Optional::stream)
.toList();
@ -161,9 +161,15 @@ public final class PackerAssetDetailsService {
walkProjection.measuredBankSizeBytes());
}
private List<String> flattenSelectedInputPaths(Map<String, List<String>> inputsByRole) {
private List<String> selectedPathsFor(PackerAssetDeclaration declaration) {
if (!declaration.artifacts().isEmpty()) {
return declaration.artifacts().stream()
.sorted(Comparator.comparingInt(PackerAssetArtifactSelection::index))
.map(PackerAssetArtifactSelection::file)
.toList();
}
final List<String> selected = new ArrayList<>();
inputsByRole.values().forEach(selected::addAll);
declaration.inputsByRole().values().forEach(selected::addAll);
return List.copyOf(selected);
}

View File

@ -245,6 +245,45 @@ final class FileSystemPackerWorkspaceServiceTest {
assertEquals(1, loader.loadCount());
}
@Test
void applyBankCompositionWritesArtifactsAndRefreshesSnapshotWithoutWholeProjectReload() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("apply-bank-composition"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
Files.writeString(assetRoot.resolve("confirm.png"), "fixture");
Files.writeString(assetRoot.resolve("cancel.png"), "fixture");
final CountingLoader loader = countingLoader();
final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);
service.listAssets(new ListAssetsRequest(project(projectRoot)));
assertEquals(1, loader.loadCount());
final var applyResult = service.applyBankComposition(new ApplyBankCompositionRequest(
project(projectRoot),
AssetReference.forAssetId(1),
List.of("cancel.png", "confirm.png")));
assertTrue(applyResult.success());
assertNull(applyResult.errorMessage());
assertEquals(1, loader.loadCount());
final var manifest = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
assertTrue(manifest.path("inputs").isObject());
assertTrue(manifest.path("inputs").isEmpty());
assertEquals("cancel.png", manifest.path("artifacts").get(0).path("file").asText());
assertEquals(0, manifest.path("artifacts").get(0).path("index").asInt());
assertEquals("confirm.png", manifest.path("artifacts").get(1).path("file").asText());
assertEquals(1, manifest.path("artifacts").get(1).path("index").asInt());
final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
project(projectRoot),
AssetReference.forAssetId(1)));
assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status());
assertEquals(
List.of("cancel.png", "confirm.png"),
detailsResult.details().bankComposition().selectedFiles().stream().map(file -> file.path()).toList());
assertEquals(1, loader.loadCount());
}
@Test
void returnsFailureWhenAssetManifestIsMissingOnContractUpdate() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("update-contract-missing-manifest"));

View File

@ -80,6 +80,33 @@ final class PackerAssetDetailsServiceTest {
.allMatch(file -> !file.displayName().isBlank()));
}
@Test
void prefersArtifactsSelectionOrderOverLegacyInputs() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed-artifacts-order"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
final BufferedImage tile = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
ImageIO.write(tile, "png", assetRoot.resolve("a.png").toFile());
ImageIO.write(tile, "png", assetRoot.resolve("b.png").toFile());
final Path manifestPath = assetRoot.resolve("asset.json");
final ObjectMapper mapper = new ObjectMapper();
final ObjectNode manifest = (ObjectNode) mapper.readTree(manifestPath.toFile());
final ObjectNode inputs = manifest.putObject("inputs");
inputs.putArray("sprites").add("a.png");
final var artifacts = manifest.putArray("artifacts");
artifacts.addObject().put("file", "b.png").put("index", 0);
artifacts.addObject().put("file", "a.png").put("index", 1);
mapper.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
final PackerAssetDetailsService service = service();
final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), AssetReference.forAssetId(1)));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
assertEquals(
List.of("b.png", "a.png"),
result.details().bankComposition().selectedFiles().stream().map(file -> file.path()).toList());
}
@Test
void returnsUnregisteredDetailsForValidUnregisteredRootReference() throws Exception {
final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan"));

View File

@ -1,8 +1,10 @@
package p.studio.workspaces.assets.details.bank;
import javafx.application.Platform;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import p.packer.messages.ApplyBankCompositionRequest;
import p.studio.Container;
import p.studio.controls.banks.StudioAssetCapacityMeter;
import p.studio.controls.forms.StudioFormActionBar;
@ -18,12 +20,14 @@ import p.studio.workspaces.assets.messages.events.StudioAssetBankCompositionAppl
import p.studio.workspaces.assets.messages.events.StudioAssetBankCompositionApplyRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetBankCompositionCapacityChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetBankCompositionDraftChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.framework.StudioSubscriptionBag;
import java.util.Objects;
public final class AssetDetailsBankCompositionControl extends VBox implements StudioControlLifecycle {
private final ProjectReference projectReference;
private final StudioWorkspaceEventBus workspaceBus;
private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag();
private final StudioFormActionBar actionBar = new StudioFormActionBar(
@ -39,7 +43,7 @@ public final class AssetDetailsBankCompositionControl extends VBox implements St
public AssetDetailsBankCompositionControl(ProjectReference projectReference, StudioWorkspaceEventBus workspaceBus) {
StudioControlLifecycleSupport.install(this, this);
Objects.requireNonNull(projectReference, "projectReference");
this.projectReference = Objects.requireNonNull(projectReference, "projectReference");
this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus");
}
@ -126,21 +130,40 @@ public final class AssetDetailsBankCompositionControl extends VBox implements St
}
private void applyDraft() {
if (viewState == null || viewState.selectedAssetReference() == null) {
if (viewState == null || viewState.selectedAssetReference() == null || !coordinator.ready()) {
return;
}
workspaceBus.publish(new StudioAssetBankCompositionApplyRequestedEvent(viewState.selectedAssetReference()));
try {
coordinator.apply();
render();
publishStateNotifications();
workspaceBus.publish(new StudioAssetBankCompositionAppliedEvent(viewState.selectedAssetReference()));
} catch (RuntimeException exception) {
workspaceBus.publish(new StudioAssetBankCompositionApplyFailedEvent(
viewState.selectedAssetReference(),
exception.getMessage()));
throw exception;
}
final var assetReference = viewState.selectedAssetReference();
final var selectedPaths = coordinator.viewModel().selectedFiles().stream()
.map(file -> file.path())
.toList();
workspaceBus.publish(new StudioAssetBankCompositionApplyRequestedEvent(assetReference));
Container.backgroundTasks().submit(() -> {
final var request = new ApplyBankCompositionRequest(
projectReference.toPackerProjectContext(),
assetReference,
selectedPaths);
try {
final var response = Container.packer().workspaceService().applyBankComposition(request);
Platform.runLater(() -> {
if (response.success()) {
coordinator.apply();
render();
publishStateNotifications();
workspaceBus.publish(new StudioAssetBankCompositionAppliedEvent(assetReference));
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(assetReference));
} else {
workspaceBus.publish(new StudioAssetBankCompositionApplyFailedEvent(
assetReference,
response.errorMessage()));
}
});
} catch (Exception exception) {
Platform.runLater(() -> workspaceBus.publish(new StudioAssetBankCompositionApplyFailedEvent(
assetReference,
exception.getMessage())));
}
});
}
private void resetDraft() {