implements packer PR-05 sensitive mutations and studio write adapter

This commit is contained in:
bQUARKz 2026-03-11 17:54:28 +00:00
parent 924ab587e8
commit c4fc6e0041
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
9 changed files with 1039 additions and 7 deletions

View File

@ -3,6 +3,7 @@ package p.packer.api.mutations;
import p.packer.api.PackerOperationStatus;
import p.packer.api.diagnostics.PackerDiagnostic;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
@ -14,7 +15,10 @@ public record PackerMutationPreview(
List<PackerProposedAction> proposedActions,
List<PackerDiagnostic> diagnostics,
List<String> blockers,
boolean highRisk) {
List<String> warnings,
List<String> safeFixes,
boolean highRisk,
Path targetAssetRoot) {
public PackerMutationPreview {
Objects.requireNonNull(status, "status");
@ -24,6 +28,9 @@ public record PackerMutationPreview(
proposedActions = List.copyOf(Objects.requireNonNull(proposedActions, "proposedActions"));
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
blockers = List.copyOf(Objects.requireNonNull(blockers, "blockers"));
warnings = List.copyOf(Objects.requireNonNull(warnings, "warnings"));
safeFixes = List.copyOf(Objects.requireNonNull(safeFixes, "safeFixes"));
targetAssetRoot = targetAssetRoot == null ? null : targetAssetRoot.toAbsolutePath().normalize();
if (summary.isBlank() || operationId.isBlank()) {
throw new IllegalArgumentException("summary and operationId must not be blank");
}

View File

@ -0,0 +1,412 @@
package p.packer.mutations;
import p.packer.api.PackerOperationClass;
import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerAssetDetails;
import p.packer.api.assets.PackerAssetState;
import p.packer.api.diagnostics.PackerDiagnostic;
import p.packer.api.events.PackerEvent;
import p.packer.api.events.PackerEventKind;
import p.packer.api.events.PackerEventSink;
import p.packer.api.mutations.PackerMutationPreview;
import p.packer.api.mutations.PackerMutationRequest;
import p.packer.api.mutations.PackerMutationResult;
import p.packer.api.mutations.PackerMutationService;
import p.packer.api.mutations.PackerMutationType;
import p.packer.api.mutations.PackerProposedAction;
import p.packer.api.workspace.GetAssetDetailsRequest;
import p.packer.declarations.PackerAssetDetailsService;
import p.packer.foundation.PackerRegistryEntry;
import p.packer.foundation.PackerRegistryState;
import p.packer.foundation.PackerWorkspaceFoundation;
import p.packer.foundation.PackerWorkspacePaths;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
public final class FileSystemPackerMutationService implements PackerMutationService {
private static final String RECOVERED_DIR = "recovered";
private static final String QUARANTINE_DIR = "quarantine";
private final PackerWorkspaceFoundation workspaceFoundation;
private final PackerAssetDetailsService detailsService;
private final PackerProjectWriteCoordinator writeCoordinator;
private final PackerEventSink eventSink;
public FileSystemPackerMutationService() {
this(new PackerWorkspaceFoundation(), new PackerAssetDetailsService(), new PackerProjectWriteCoordinator(), PackerEventSink.noop());
}
public FileSystemPackerMutationService(
PackerWorkspaceFoundation workspaceFoundation,
PackerAssetDetailsService detailsService,
PackerProjectWriteCoordinator writeCoordinator,
PackerEventSink eventSink) {
this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation");
this.detailsService = Objects.requireNonNull(detailsService, "detailsService");
this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator");
this.eventSink = Objects.requireNonNull(eventSink, "eventSink");
}
@Override
public PackerOperationClass operationClass() {
return PackerOperationClass.WORKSPACE_MUTATION;
}
@Override
public PackerMutationPreview preview(PackerMutationRequest request) {
final ResolvedMutationContext context = resolveContext(Objects.requireNonNull(request, "request"));
final PackerMutationPreview preview = buildPreview(context);
emit(context.project(), preview.operationId(), 0L, PackerEventKind.PREVIEW_READY, preview.summary(), affectedAssets(preview));
return preview;
}
@Override
public PackerMutationResult apply(PackerMutationPreview preview) {
Objects.requireNonNull(preview, "preview");
final PackerProjectContext project = preview.request().project();
try {
if (!preview.canApply()) {
throw new PackerMutationException("Cannot apply mutation preview with blockers");
}
final PackerMutationResult result = writeCoordinator.withWriteLock(project, () -> applyLocked(preview));
emit(project, result.operationId(), 1L, PackerEventKind.ACTION_APPLIED, result.summary(), affectedAssets(preview));
return result;
} catch (RuntimeException exception) {
emit(project, preview.operationId(), 1L, PackerEventKind.ACTION_FAILED, rootCauseMessage(exception), affectedAssets(preview));
throw exception;
}
}
private PackerMutationResult applyLocked(PackerMutationPreview preview) {
final PackerProjectContext project = preview.request().project();
final PackerRegistryState registry = workspaceFoundation.loadRegistry(project);
final ResolvedMutationContext context = resolveContext(preview.request());
final Path assetRoot = context.assetDetails().summary().identity().assetRoot();
return switch (preview.request().type()) {
case REGISTER_ASSET, ADOPT_ASSET -> applyRegister(project, registry, assetRoot, preview);
case FORGET_ASSET -> applyForget(project, registry, assetRoot, preview);
case REMOVE_ASSET -> applyRemove(project, registry, assetRoot, preview);
case QUARANTINE_ASSET -> applyQuarantine(project, registry, assetRoot, preview);
case RELOCATE_ASSET -> applyRelocate(project, registry, assetRoot, preview);
};
}
private PackerMutationResult applyRegister(
PackerProjectContext project,
PackerRegistryState registry,
Path assetRoot,
PackerMutationPreview preview) {
final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, assetRoot);
final Optional<PackerRegistryEntry> existing = workspaceFoundation.lookup().findByRoot(project, registry, assetRoot);
if (existing.isPresent()) {
return new PackerMutationResult(
PackerOperationStatus.SUCCESS,
"Asset is already registered.",
preview.operationId(),
List.of(),
List.of());
}
final PackerRegistryEntry entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot);
final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry);
workspaceFoundation.saveRegistry(project, updated);
return new PackerMutationResult(
PackerOperationStatus.SUCCESS,
"Asset registered: " + relativeRoot,
preview.operationId(),
preview.proposedActions(),
List.of());
}
private PackerMutationResult applyForget(
PackerProjectContext project,
PackerRegistryState registry,
Path assetRoot,
PackerMutationPreview preview) {
final PackerRegistryState updated = registry.withAssets(
registry.assets().stream()
.filter(entry -> !PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot))
.toList(),
registry.nextAssetId());
workspaceFoundation.saveRegistry(project, updated);
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset forgotten.", preview.operationId(), preview.proposedActions(), List.of());
}
private PackerMutationResult applyRemove(
PackerProjectContext project,
PackerRegistryState registry,
Path assetRoot,
PackerMutationPreview preview) {
final PackerRegistryState updated = registry.withAssets(
registry.assets().stream()
.filter(entry -> !PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot))
.toList(),
registry.nextAssetId());
workspaceFoundation.saveRegistry(project, updated);
deleteRecursively(assetRoot);
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset removed from workspace.", preview.operationId(), preview.proposedActions(), List.of());
}
private PackerMutationResult applyQuarantine(
PackerProjectContext project,
PackerRegistryState registry,
Path assetRoot,
PackerMutationPreview preview) {
final PackerRegistryState updated = registry.withAssets(
registry.assets().stream()
.filter(entry -> !PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot))
.toList(),
registry.nextAssetId());
workspaceFoundation.saveRegistry(project, updated);
moveAssetRoot(assetRoot, requireTarget(preview));
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset moved to quarantine.", preview.operationId(), preview.proposedActions(), List.of());
}
private PackerMutationResult applyRelocate(
PackerProjectContext project,
PackerRegistryState registry,
Path assetRoot,
PackerMutationPreview preview) {
final Path targetRoot = requireTarget(preview);
final PackerRegistryState updated = registry.withAssets(
registry.assets().stream()
.map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot)
? new PackerRegistryEntry(entry.assetId(), entry.assetUuid(), PackerWorkspacePaths.relativeAssetRoot(project, targetRoot))
: entry)
.toList(),
registry.nextAssetId());
workspaceFoundation.saveRegistry(project, updated);
moveAssetRoot(assetRoot, targetRoot);
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset root relocated.", preview.operationId(), preview.proposedActions(), List.of());
}
private PackerMutationPreview buildPreview(ResolvedMutationContext context) {
final PackerMutationRequest request = context.request();
final PackerAssetDetails assetDetails = context.assetDetails();
final Path assetRoot = assetDetails.summary().identity().assetRoot();
final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(context.project(), assetRoot);
final boolean managed = assetDetails.summary().identity().assetId() != null;
final List<PackerProposedAction> actions = new ArrayList<>();
final List<String> blockers = new ArrayList<>(context.initialBlockers());
final List<String> warnings = new ArrayList<>();
final List<String> safeFixes = new ArrayList<>();
Path targetAssetRoot = null;
switch (request.type()) {
case REGISTER_ASSET, ADOPT_ASSET -> {
if (managed) {
blockers.add("Asset is already managed.");
}
if (assetDetails.summary().state() == PackerAssetState.INVALID) {
blockers.add("Asset declaration must be valid before registration.");
}
if (!blockers.isEmpty()) {
break;
}
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "ADD", relativeRoot));
if (assetDetails.summary().hasDiagnostics()) {
warnings.add("Asset currently reports diagnostics and will still be registered.");
}
}
case FORGET_ASSET -> {
if (!managed) {
blockers.add("Only managed assets can be forgotten.");
} else {
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot));
warnings.add("The asset will leave the managed build set.");
}
}
case REMOVE_ASSET -> {
if (managed) {
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot));
}
actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "DELETE", relativeRoot));
warnings.add("Physical files inside the asset root will be deleted.");
}
case QUARANTINE_ASSET -> {
if (isInsideQuarantine(context.project(), assetRoot)) {
blockers.add("Asset is already inside quarantine.");
} else {
targetAssetRoot = request.targetRoot() != null
? request.targetRoot()
: nextAvailablePath(quarantineRoot(context.project()), sanitizeSegment(assetDetails.summary().identity().assetName()));
if (managed) {
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot));
warnings.add("Quarantining a managed asset removes it from the active registry.");
}
actions.add(new PackerProposedAction(
PackerOperationClass.WORKSPACE_MUTATION,
"MOVE",
relativeRoot + " -> " + PackerWorkspacePaths.relativeAssetRoot(context.project(), targetAssetRoot)));
warnings.add("Quarantine is explicit and reversible, but the asset will leave its current workspace location.");
}
}
case RELOCATE_ASSET -> {
targetAssetRoot = request.targetRoot() != null
? request.targetRoot()
: relocationTarget(context.project(), assetRoot, assetDetails.summary().identity().assetName());
final String targetRelativeRoot = PackerWorkspacePaths.relativeAssetRoot(context.project(), targetAssetRoot);
if (assetRoot.equals(targetAssetRoot)) {
blockers.add("Asset is already at the planned relocation target.");
} else {
if (managed) {
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot + " -> " + targetRelativeRoot));
}
actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "MOVE", relativeRoot + " -> " + targetRelativeRoot));
warnings.add("Relocation preserves asset identity, but it changes the root path seen by the workspace.");
}
}
}
return new PackerMutationPreview(
blockers.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL,
"Mutation preview ready for " + request.type().name().toLowerCase(),
UUID.randomUUID().toString(),
request,
actions,
assetDetails.diagnostics(),
blockers,
warnings,
safeFixes,
request.type() == PackerMutationType.REMOVE_ASSET || request.type() == PackerMutationType.RELOCATE_ASSET,
targetAssetRoot);
}
private ResolvedMutationContext resolveContext(PackerMutationRequest request) {
final PackerAssetDetails assetDetails = detailsService.getAssetDetails(
new GetAssetDetailsRequest(request.project(), request.assetReference())).details();
final List<String> initialBlockers = assetDetails.diagnostics().stream()
.filter(PackerDiagnostic::blocking)
.map(PackerDiagnostic::message)
.filter(message -> request.type() != PackerMutationType.FORGET_ASSET
&& request.type() != PackerMutationType.REMOVE_ASSET
&& request.type() != PackerMutationType.QUARANTINE_ASSET
&& request.type() != PackerMutationType.RELOCATE_ASSET)
.toList();
return new ResolvedMutationContext(request.project(), request, assetDetails, initialBlockers);
}
private Path quarantineRoot(PackerProjectContext project) {
return PackerWorkspacePaths.registryDirectory(project).resolve(QUARANTINE_DIR).toAbsolutePath().normalize();
}
private boolean isInsideQuarantine(PackerProjectContext project, Path assetRoot) {
return assetRoot.toAbsolutePath().normalize().startsWith(quarantineRoot(project));
}
private Path relocationTarget(PackerProjectContext project, Path assetRoot, String assetName) {
if (isInsideQuarantine(project, assetRoot)) {
return nextAvailablePath(PackerWorkspacePaths.assetsRoot(project).resolve(RECOVERED_DIR), sanitizeSegment(assetName));
}
final Path siblingParent = assetRoot.getParent() == null ? PackerWorkspacePaths.assetsRoot(project) : assetRoot.getParent();
return nextAvailablePath(siblingParent, assetRoot.getFileName().toString() + "-relocated");
}
private Path nextAvailablePath(Path parent, String baseName) {
final Path normalizedParent = parent.toAbsolutePath().normalize();
Path candidate = normalizedParent.resolve(baseName);
int index = 2;
while (Files.exists(candidate)) {
candidate = normalizedParent.resolve(baseName + "-" + index);
index += 1;
}
return candidate;
}
private String sanitizeSegment(String value) {
final String sanitized = value == null
? "asset"
: value.trim()
.replaceAll("[^A-Za-z0-9._-]+", "-")
.replaceAll("-{2,}", "-")
.replaceAll("^[.-]+|[.-]+$", "");
return sanitized.isBlank() ? "asset" : sanitized;
}
private void moveAssetRoot(Path sourceRoot, Path targetRoot) {
if (sourceRoot.equals(targetRoot)) {
return;
}
try {
Files.createDirectories(targetRoot.getParent());
Files.move(sourceRoot, targetRoot);
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
}
private void deleteRecursively(Path root) {
if (!Files.exists(root)) {
return;
}
try (Stream<Path> stream = Files.walk(root)) {
stream.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
});
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
}
private Path requireTarget(PackerMutationPreview preview) {
if (preview.targetAssetRoot() == null) {
throw new PackerMutationException("Mutation preview does not define a target asset root");
}
return preview.targetAssetRoot();
}
private void emit(
PackerProjectContext project,
String operationId,
long sequence,
PackerEventKind kind,
String summary,
List<String> affectedAssets) {
eventSink.publish(new PackerEvent(
project.projectId(),
operationId,
sequence,
kind,
Instant.now(),
summary,
null,
affectedAssets));
}
private List<String> affectedAssets(PackerMutationPreview preview) {
return List.of(preview.request().assetReference());
}
private String rootCauseMessage(Throwable throwable) {
Throwable current = throwable;
while (current.getCause() != null) {
current = current.getCause();
}
return current.getMessage() == null || current.getMessage().isBlank()
? current.getClass().getSimpleName()
: current.getMessage();
}
private record ResolvedMutationContext(
PackerProjectContext project,
PackerMutationRequest request,
PackerAssetDetails assetDetails,
List<String> initialBlockers) {
}
}

View File

@ -0,0 +1,11 @@
package p.packer.mutations;
public final class PackerMutationException extends RuntimeException {
public PackerMutationException(String message) {
super(message);
}
public PackerMutationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,25 @@
package p.packer.mutations;
import p.packer.api.PackerProjectContext;
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Supplier;
public final class PackerProjectWriteCoordinator {
private final ConcurrentMap<Path, ReentrantLock> locks = new ConcurrentHashMap<>();
public <T> T withWriteLock(PackerProjectContext project, Supplier<T> work) {
final Path key = Objects.requireNonNull(project, "project").rootPath().toAbsolutePath().normalize();
final ReentrantLock lock = locks.computeIfAbsent(key, ignored -> new ReentrantLock());
lock.lock();
try {
return Objects.requireNonNull(work, "work").get();
} finally {
lock.unlock();
}
}
}

View File

@ -0,0 +1,179 @@
package p.packer.mutations;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.api.PackerProjectContext;
import p.packer.api.events.PackerEvent;
import p.packer.api.events.PackerEventKind;
import p.packer.api.mutations.PackerMutationPreview;
import p.packer.api.mutations.PackerMutationRequest;
import p.packer.api.mutations.PackerMutationType;
import p.packer.declarations.PackerAssetDetailsService;
import p.packer.foundation.PackerWorkspaceFoundation;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.jupiter.api.Assertions.*;
final class FileSystemPackerMutationServiceTest {
@TempDir
Path tempDir;
@Test
void previewAndApplyQuarantineForManagedAssetShowsStructuredImpact() throws Exception {
final Path projectRoot = createManagedAssetProject();
final List<PackerEvent> events = new CopyOnWriteArrayList<>();
final FileSystemPackerMutationService service = service(events);
final PackerProjectContext project = project(projectRoot);
final PackerMutationPreview preview = service.preview(new PackerMutationRequest(
project,
PackerMutationType.QUARANTINE_ASSET,
"1",
null));
assertTrue(preview.canApply());
assertFalse(preview.highRisk());
assertNotNull(preview.targetAssetRoot());
assertEquals(1, preview.proposedActions().stream().filter(action -> action.operationClass() == p.packer.api.PackerOperationClass.REGISTRY_MUTATION).count());
assertEquals(1, preview.proposedActions().stream().filter(action -> action.operationClass() == p.packer.api.PackerOperationClass.WORKSPACE_MUTATION).count());
service.apply(preview);
assertFalse(Files.exists(projectRoot.resolve("assets/ui/atlas")));
assertTrue(Files.isDirectory(preview.targetAssetRoot()));
final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json"));
assertFalse(registryJson.contains("\"root\" : \"ui/atlas\""));
assertEquals(List.of(PackerEventKind.PREVIEW_READY, PackerEventKind.ACTION_APPLIED), events.stream().map(PackerEvent::kind).toList());
assertEquals(preview.operationId(), events.getFirst().operationId());
assertEquals(preview.operationId(), events.get(1).operationId());
}
@Test
void applyRelocatePreservesIdentityAndUpdatesRegistryRoot() throws Exception {
final Path projectRoot = createManagedAssetProject();
final FileSystemPackerMutationService service = service(new CopyOnWriteArrayList<>());
final PackerProjectContext project = project(projectRoot);
final PackerMutationPreview preview = service.preview(new PackerMutationRequest(
project,
PackerMutationType.RELOCATE_ASSET,
"1",
null));
assertTrue(preview.canApply());
assertTrue(preview.highRisk());
service.apply(preview);
assertFalse(Files.exists(projectRoot.resolve("assets/ui/atlas")));
assertTrue(Files.isDirectory(preview.targetAssetRoot()));
final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json"));
assertTrue(registryJson.contains("\"asset_id\" : 1"));
assertTrue(registryJson.contains("\"asset_uuid\" : \"uuid-1\""));
assertTrue(registryJson.contains(relativeAssetRoot(project, preview.targetAssetRoot())));
}
@Test
void previewRegisterBlocksInvalidAssetDeclarations() throws Exception {
final Path projectRoot = tempDir.resolve("invalid");
final Path assetRoot = projectRoot.resolve("assets/ui/broken");
Files.createDirectories(assetRoot);
Files.writeString(assetRoot.resolve("asset.json"), """
{
"schema_version": 1,
"type": "image_bank"
}
""");
final FileSystemPackerMutationService service = service(new CopyOnWriteArrayList<>());
final PackerMutationPreview preview = service.preview(new PackerMutationRequest(
project(projectRoot),
PackerMutationType.REGISTER_ASSET,
"ui/broken",
null));
assertFalse(preview.canApply());
assertTrue(preview.blockers().stream().anyMatch(message -> message.contains("valid before registration")));
}
@Test
void emitsFailureLifecycleWhenApplyFails() throws Exception {
final Path projectRoot = createManagedAssetProject();
final List<PackerEvent> events = new CopyOnWriteArrayList<>();
final FileSystemPackerMutationService service = service(events);
final PackerMutationPreview preview = service.preview(new PackerMutationRequest(
project(projectRoot),
PackerMutationType.RELOCATE_ASSET,
"1",
null));
deleteRecursively(projectRoot.resolve("assets/ui/atlas"));
assertThrows(UncheckedIOException.class, () -> service.apply(preview));
assertEquals(PackerEventKind.ACTION_FAILED, events.getLast().kind());
assertEquals(preview.operationId(), events.getLast().operationId());
}
private FileSystemPackerMutationService service(List<PackerEvent> events) {
return new FileSystemPackerMutationService(
new PackerWorkspaceFoundation(),
new PackerAssetDetailsService(),
new PackerProjectWriteCoordinator(),
events::add);
}
private Path createManagedAssetProject() throws Exception {
final Path projectRoot = tempDir.resolve("main");
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
Files.createDirectories(assetRoot);
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
Files.writeString(assetRoot.resolve("asset.json"), """
{
"schema_version": 1,
"name": "ui_atlas",
"type": "image_bank",
"preload": { "enabled": true }
}
""");
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """
{
"schema_version": 1,
"next_asset_id": 2,
"assets": [
{
"asset_id": 1,
"asset_uuid": "uuid-1",
"root": "ui/atlas"
}
]
}
""");
return projectRoot;
}
private PackerProjectContext project(Path root) {
return new PackerProjectContext("main", root);
}
private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) {
return project.rootPath().resolve("assets").toAbsolutePath().normalize()
.relativize(assetRoot.toAbsolutePath().normalize())
.toString()
.replace('\\', '/');
}
private void deleteRecursively(Path root) throws IOException {
try (var stream = Files.walk(root)) {
for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}

View File

@ -0,0 +1,65 @@
package p.packer.mutations;
import org.junit.jupiter.api.Test;
import p.packer.api.PackerProjectContext;
import java.nio.file.Path;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class PackerProjectWriteCoordinatorTest {
@Test
void serializesWritesWithinTheSameProject() throws Exception {
final PackerProjectWriteCoordinator coordinator = new PackerProjectWriteCoordinator();
final PackerProjectContext project = new PackerProjectContext("main", Path.of("/tmp/main"));
final AtomicInteger concurrentWriters = new AtomicInteger();
final AtomicInteger maxConcurrentWriters = new AtomicInteger();
final CountDownLatch firstEntered = new CountDownLatch(1);
final CountDownLatch releaseFirst = new CountDownLatch(1);
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
final Future<?> first = executor.submit(() -> coordinator.withWriteLock(project, () -> {
final int current = concurrentWriters.incrementAndGet();
maxConcurrentWriters.accumulateAndGet(current, Math::max);
firstEntered.countDown();
await(releaseFirst);
concurrentWriters.decrementAndGet();
return null;
}));
assertTrue(firstEntered.await(2, TimeUnit.SECONDS));
final Future<?> second = executor.submit(() -> coordinator.withWriteLock(project, () -> {
final int current = concurrentWriters.incrementAndGet();
maxConcurrentWriters.accumulateAndGet(current, Math::max);
concurrentWriters.decrementAndGet();
return null;
}));
Thread.sleep(100L);
assertFalse(second.isDone());
releaseFirst.countDown();
first.get(2, TimeUnit.SECONDS);
second.get(2, TimeUnit.SECONDS);
}
assertEquals(1, maxConcurrentWriters.get());
}
private void await(CountDownLatch latch) {
try {
latch.await(2, TimeUnit.SECONDS);
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
throw new AssertionError(interruptedException);
}
}
}

View File

@ -56,17 +56,31 @@ public final class AssetWorkspace implements Workspace {
private String searchQuery = "";
public AssetWorkspace(ProjectReference projectReference) {
this(projectReference, new PackerBackedAssetWorkspaceService(), new FileSystemAssetWorkspaceMutationService());
this(
projectReference,
new PackerBackedAssetWorkspaceService(),
defaultWorkspaceBus(),
null);
}
public AssetWorkspace(
ProjectReference projectReference,
AssetWorkspaceService assetWorkspaceService,
AssetWorkspaceMutationService mutationService) {
this(projectReference, assetWorkspaceService, defaultWorkspaceBus(), mutationService);
}
private AssetWorkspace(
ProjectReference projectReference,
AssetWorkspaceService assetWorkspaceService,
StudioWorkspaceEventBus workspaceBus,
AssetWorkspaceMutationService mutationService) {
this.projectReference = Objects.requireNonNull(projectReference, "projectReference");
this.assetWorkspaceService = Objects.requireNonNull(assetWorkspaceService, "assetWorkspaceService");
this.mutationService = Objects.requireNonNull(mutationService, "mutationService");
this.workspaceBus = new StudioWorkspaceEventBus(WorkspaceId.ASSETS, Container.events());
this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus");
this.mutationService = mutationService == null
? new PackerBackedAssetWorkspaceMutationService(this.workspaceBus)
: Objects.requireNonNull(mutationService, "mutationService");
root.getStyleClass().add("assets-workspace");
root.setCenter(buildLayout());
@ -98,6 +112,10 @@ public final class AssetWorkspace implements Workspace {
return state;
}
private static StudioWorkspaceEventBus defaultWorkspaceBus() {
return new StudioWorkspaceEventBus(WorkspaceId.ASSETS, Container.events());
}
private VBox buildLayout() {
inlineProgressLabel.getStyleClass().add("assets-workspace-inline-progress-label");
inlineProgressLabel.setText(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE));
@ -699,7 +717,6 @@ public final class AssetWorkspace implements Workspace {
final AssetWorkspaceMutationPreview preview = mutationService.preview(projectReference, selectedAsset, action);
stagedMutationPreview = preview;
appendLog("Preview ready for " + actionLabel(action) + ".");
workspaceBus.publish(new StudioAssetsMutationPreviewReadyEvent(projectReference, action, 1));
renderState();
}
@ -802,13 +819,11 @@ public final class AssetWorkspace implements Workspace {
try {
mutationService.apply(projectReference, preview);
appendLog("Applied " + actionLabel(preview.action()) + ".");
workspaceBus.publish(new StudioAssetsMutationAppliedEvent(projectReference, preview.action(), 1));
stagedMutationPreview = null;
refresh();
} catch (RuntimeException runtimeException) {
final String message = rootCauseMessage(runtimeException);
appendLog("Mutation failed: " + message);
workspaceBus.publish(new StudioAssetsMutationFailedEvent(projectReference, preview.action(), message));
stagedMutationPreview = preview;
renderState();
}

View File

@ -0,0 +1,170 @@
package p.studio.workspaces.assets;
import p.packer.api.PackerOperationClass;
import p.packer.api.PackerProjectContext;
import p.packer.api.events.PackerEvent;
import p.packer.api.events.PackerEventKind;
import p.packer.api.mutations.PackerMutationPreview;
import p.packer.api.mutations.PackerMutationRequest;
import p.packer.api.mutations.PackerMutationType;
import p.packer.api.mutations.PackerProposedAction;
import p.packer.declarations.PackerAssetDetailsService;
import p.packer.foundation.PackerWorkspaceFoundation;
import p.packer.mutations.FileSystemPackerMutationService;
import p.packer.mutations.PackerProjectWriteCoordinator;
import p.studio.events.StudioAssetsMutationAppliedEvent;
import p.studio.events.StudioAssetsMutationFailedEvent;
import p.studio.events.StudioAssetsMutationPreviewReadyEvent;
import p.studio.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class PackerBackedAssetWorkspaceMutationService implements AssetWorkspaceMutationService {
private final FileSystemPackerMutationService packerMutationService;
private final StudioWorkspaceEventBus eventBus;
private final ConcurrentMap<AssetWorkspaceMutationPreview, OperationSession> previewSessions = new ConcurrentHashMap<>();
private final ConcurrentMap<String, OperationSession> operationSessions = new ConcurrentHashMap<>();
public PackerBackedAssetWorkspaceMutationService(StudioWorkspaceEventBus eventBus) {
this(eventBus, new PackerWorkspaceFoundation(), new PackerAssetDetailsService(), new PackerProjectWriteCoordinator());
}
PackerBackedAssetWorkspaceMutationService(
StudioWorkspaceEventBus eventBus,
PackerWorkspaceFoundation workspaceFoundation,
PackerAssetDetailsService detailsService,
PackerProjectWriteCoordinator writeCoordinator) {
this.eventBus = Objects.requireNonNull(eventBus, "eventBus");
this.packerMutationService = new FileSystemPackerMutationService(
Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"),
Objects.requireNonNull(detailsService, "detailsService"),
Objects.requireNonNull(writeCoordinator, "writeCoordinator"),
this::forwardLifecycleEvent);
}
@Override
public AssetWorkspaceMutationPreview preview(ProjectReference projectReference, AssetWorkspaceAssetSummary asset, AssetWorkspaceAction action) {
Objects.requireNonNull(projectReference, "projectReference");
Objects.requireNonNull(asset, "asset");
final PackerMutationPreview packerPreview = packerMutationService.preview(
new PackerMutationRequest(
project(projectReference),
mutationType(action),
assetReference(projectReference, asset),
null));
final AssetWorkspaceMutationPreview studioPreview = mapPreview(action, asset, packerPreview);
final OperationSession session = new OperationSession(projectReference, action, studioPreview, packerPreview);
previewSessions.put(studioPreview, session);
operationSessions.put(packerPreview.operationId(), session);
eventBus.publish(new StudioAssetsMutationPreviewReadyEvent(projectReference, action, 1));
return studioPreview;
}
@Override
public void apply(ProjectReference projectReference, AssetWorkspaceMutationPreview preview) {
Objects.requireNonNull(projectReference, "projectReference");
final OperationSession session = previewSessions.get(Objects.requireNonNull(preview, "preview"));
if (session == null) {
throw new IllegalStateException("Mutation preview is not backed by a packer preview.");
}
try {
packerMutationService.apply(session.packerPreview());
} catch (RuntimeException exception) {
throw exception;
}
}
void forwardLifecycleEvent(PackerEvent event) {
final OperationSession session = operationSessions.get(Objects.requireNonNull(event, "event").operationId());
if (session == null) {
return;
}
if (event.kind() == PackerEventKind.ACTION_APPLIED) {
previewSessions.remove(session.studioPreview());
operationSessions.remove(event.operationId());
eventBus.publish(new StudioAssetsMutationAppliedEvent(
session.projectReference(),
session.action(),
affectedAssetCount(event)));
return;
}
if (event.kind() == PackerEventKind.ACTION_FAILED) {
eventBus.publish(new StudioAssetsMutationFailedEvent(
session.projectReference(),
session.action(),
event.summary()));
}
}
private AssetWorkspaceMutationPreview mapPreview(
AssetWorkspaceAction action,
AssetWorkspaceAssetSummary asset,
PackerMutationPreview preview) {
final List<AssetWorkspaceMutationChange> changes = preview.proposedActions().stream()
.map(this::mapChange)
.toList();
return new AssetWorkspaceMutationPreview(
action,
asset,
preview.blockers(),
preview.warnings(),
preview.safeFixes(),
changes,
preview.highRisk(),
preview.targetAssetRoot());
}
private AssetWorkspaceMutationChange mapChange(PackerProposedAction action) {
return new AssetWorkspaceMutationChange(
scope(action.operationClass()),
action.verb(),
action.target());
}
private AssetWorkspaceMutationChangeScope scope(PackerOperationClass operationClass) {
return switch (operationClass) {
case REGISTRY_MUTATION -> AssetWorkspaceMutationChangeScope.REGISTRY;
case WORKSPACE_MUTATION, READ_ONLY -> AssetWorkspaceMutationChangeScope.WORKSPACE;
};
}
private PackerMutationType mutationType(AssetWorkspaceAction action) {
return switch (Objects.requireNonNull(action, "action")) {
case ADOPT -> PackerMutationType.ADOPT_ASSET;
case REGISTER -> PackerMutationType.REGISTER_ASSET;
case QUARANTINE -> PackerMutationType.QUARANTINE_ASSET;
case RELOCATE -> PackerMutationType.RELOCATE_ASSET;
case FORGET -> PackerMutationType.FORGET_ASSET;
case REMOVE -> PackerMutationType.REMOVE_ASSET;
case DOCTOR, BUILD -> throw new IllegalArgumentException("Action is not supported by the staged mutation flow: " + action);
};
}
private PackerProjectContext project(ProjectReference projectReference) {
return new PackerProjectContext(projectReference.name(), projectReference.rootPath());
}
private String assetReference(ProjectReference projectReference, AssetWorkspaceAssetSummary asset) {
if (asset.assetId() != null) {
return Integer.toString(asset.assetId());
}
final Path assetsRoot = projectReference.rootPath().resolve("assets").toAbsolutePath().normalize();
return assetsRoot.relativize(asset.assetRoot().toAbsolutePath().normalize()).toString().replace('\\', '/');
}
private int affectedAssetCount(PackerEvent event) {
return event.affectedAssets().isEmpty() ? 1 : event.affectedAssets().size();
}
private record OperationSession(
ProjectReference projectReference,
AssetWorkspaceAction action,
AssetWorkspaceMutationPreview studioPreview,
PackerMutationPreview packerPreview) {
}
}

View File

@ -0,0 +1,148 @@
package p.studio.workspaces.assets;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.declarations.PackerAssetDetailsService;
import p.packer.foundation.PackerWorkspaceFoundation;
import p.packer.mutations.PackerProjectWriteCoordinator;
import p.studio.events.StudioAssetsMutationAppliedEvent;
import p.studio.events.StudioAssetsMutationFailedEvent;
import p.studio.events.StudioAssetsMutationPreviewReadyEvent;
import p.studio.events.StudioEventBus;
import p.studio.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference;
import p.studio.workspaces.WorkspaceId;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class PackerBackedAssetWorkspaceMutationServiceTest {
@TempDir
Path tempDir;
@Test
void previewMapsPackerImpactAndPublishesPreviewReadyEvent() throws Exception {
final Path projectRoot = createManagedAssetProject();
final StudioEventBus globalBus = new StudioEventBus();
final List<StudioAssetsMutationPreviewReadyEvent> previewEvents = new ArrayList<>();
globalBus.subscribe(StudioAssetsMutationPreviewReadyEvent.class, previewEvents::add);
final PackerBackedAssetWorkspaceMutationService service = service(globalBus);
final AssetWorkspaceMutationPreview preview = service.preview(
project("Main", projectRoot),
managedAsset(projectRoot),
AssetWorkspaceAction.QUARANTINE);
assertEquals(1, previewEvents.size());
assertTrue(preview.canApply());
assertNotNull(preview.targetAssetRoot());
assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY).count());
assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE).count());
}
@Test
void applyPublishesAppliedEventFromPackerLifecycle() throws Exception {
final Path projectRoot = createManagedAssetProject();
final StudioEventBus globalBus = new StudioEventBus();
final List<StudioAssetsMutationAppliedEvent> appliedEvents = new ArrayList<>();
globalBus.subscribe(StudioAssetsMutationAppliedEvent.class, appliedEvents::add);
final PackerBackedAssetWorkspaceMutationService service = service(globalBus);
final AssetWorkspaceMutationPreview preview = service.preview(
project("Main", projectRoot),
managedAsset(projectRoot),
AssetWorkspaceAction.RELOCATE);
service.apply(project("Main", projectRoot), preview);
assertEquals(1, appliedEvents.size());
assertEquals(AssetWorkspaceAction.RELOCATE, appliedEvents.getFirst().action());
assertTrue(Files.isDirectory(preview.targetAssetRoot()));
}
@Test
void applyFailurePublishesFailedEventFromPackerLifecycle() throws Exception {
final Path projectRoot = createManagedAssetProject();
final StudioEventBus globalBus = new StudioEventBus();
final List<StudioAssetsMutationFailedEvent> failedEvents = new ArrayList<>();
globalBus.subscribe(StudioAssetsMutationFailedEvent.class, failedEvents::add);
final PackerBackedAssetWorkspaceMutationService service = service(globalBus);
final AssetWorkspaceMutationPreview preview = service.preview(
project("Main", projectRoot),
managedAsset(projectRoot),
AssetWorkspaceAction.RELOCATE);
deleteRecursively(projectRoot.resolve("assets/ui/atlas"));
assertThrows(RuntimeException.class, () -> service.apply(project("Main", projectRoot), preview));
assertEquals(1, failedEvents.size());
assertEquals(AssetWorkspaceAction.RELOCATE, failedEvents.getFirst().action());
assertFalse(failedEvents.getFirst().message().isBlank());
}
private PackerBackedAssetWorkspaceMutationService service(StudioEventBus globalBus) {
return new PackerBackedAssetWorkspaceMutationService(
new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus),
new PackerWorkspaceFoundation(),
new PackerAssetDetailsService(),
new PackerProjectWriteCoordinator());
}
private Path createManagedAssetProject() throws Exception {
final Path projectRoot = tempDir.resolve("main");
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
Files.createDirectories(assetRoot);
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
Files.writeString(assetRoot.resolve("asset.json"), """
{
"schema_version": 1,
"name": "ui_atlas",
"type": "image_bank",
"preload": { "enabled": true }
}
""");
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """
{
"schema_version": 1,
"next_asset_id": 2,
"assets": [
{
"asset_id": 1,
"asset_uuid": "uuid-1",
"root": "ui/atlas"
}
]
}
""");
return projectRoot;
}
private AssetWorkspaceAssetSummary managedAsset(Path projectRoot) {
return new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.ManagedAsset(1),
"ui_atlas",
AssetWorkspaceAssetState.MANAGED,
1,
"image_bank",
projectRoot.resolve("assets/ui/atlas"),
true,
false);
}
private ProjectReference project(String name, Path root) {
return new ProjectReference(name, "1.0.0", "pbs", 1, root);
}
private void deleteRecursively(Path root) throws IOException {
try (var stream = Files.walk(root)) {
for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}