diff --git a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationPreview.java b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationPreview.java index 0472a05d..b9fc24ca 100644 --- a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationPreview.java +++ b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationPreview.java @@ -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 proposedActions, List diagnostics, List blockers, - boolean highRisk) { + List warnings, + List 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"); } diff --git a/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java b/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java new file mode 100644 index 00000000..d33e24e7 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java @@ -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 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 actions = new ArrayList<>(); + final List blockers = new ArrayList<>(context.initialBlockers()); + final List warnings = new ArrayList<>(); + final List 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 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 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 affectedAssets) { + eventSink.publish(new PackerEvent( + project.projectId(), + operationId, + sequence, + kind, + Instant.now(), + summary, + null, + affectedAssets)); + } + + private List 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 initialBlockers) { + } +} diff --git a/prometeu-packer/src/main/java/p/packer/mutations/PackerMutationException.java b/prometeu-packer/src/main/java/p/packer/mutations/PackerMutationException.java new file mode 100644 index 00000000..778d12a6 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/mutations/PackerMutationException.java @@ -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); + } +} diff --git a/prometeu-packer/src/main/java/p/packer/mutations/PackerProjectWriteCoordinator.java b/prometeu-packer/src/main/java/p/packer/mutations/PackerProjectWriteCoordinator.java new file mode 100644 index 00000000..f063ab40 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/mutations/PackerProjectWriteCoordinator.java @@ -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 locks = new ConcurrentHashMap<>(); + + public T withWriteLock(PackerProjectContext project, Supplier 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(); + } + } +} diff --git a/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java b/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java new file mode 100644 index 00000000..18aaa860 --- /dev/null +++ b/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java @@ -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 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 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 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); + } + } + } +} diff --git a/prometeu-packer/src/test/java/p/packer/mutations/PackerProjectWriteCoordinatorTest.java b/prometeu-packer/src/test/java/p/packer/mutations/PackerProjectWriteCoordinatorTest.java new file mode 100644 index 00000000..17078c4d --- /dev/null +++ b/prometeu-packer/src/test/java/p/packer/mutations/PackerProjectWriteCoordinatorTest.java @@ -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); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java index 7ff7c1f2..e94452f5 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java @@ -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(); } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java new file mode 100644 index 00000000..3a285bb0 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java @@ -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 previewSessions = new ConcurrentHashMap<>(); + private final ConcurrentMap 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 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) { + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java new file mode 100644 index 00000000..e51e115f --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java @@ -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 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 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 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); + } + } + } +}