implements PR-020

This commit is contained in:
bQUARKz 2026-03-16 07:51:44 +00:00
parent 60a0b571f8
commit 4278d045c2
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
29 changed files with 943 additions and 80 deletions

View File

@ -0,0 +1,101 @@
# PR-20 Asset Action Capabilities and Register-First Delivery
Domain Owner: `docs/packer`
Cross-Domain Impact: `docs/studio`
## Briefing
The current `Assets` workspace still decides too much locally about which asset actions should appear and when they should be allowed.
That is the wrong long-term boundary.
The packer already owns asset semantics, registration state, build participation, and write execution. It should also own the capability decision for asset actions, while Studio remains a consumer that renders the actions exposed by the service.
This PR introduces a capability-based action contract driven by the packer, with `AssetAction.REGISTER` as the first delivered action end to end.
## Objective
Add a packer-owned asset action capability API and deliver the first action, `REGISTER`, through the full packer-to-Studio path.
## Dependencies
- [`./PR-15-snapshot-backed-asset-query-services.md`](./PR-15-snapshot-backed-asset-query-services.md)
- [`./PR-16-write-lane-command-completion-and-used-write-services.md`](./PR-16-write-lane-command-completion-and-used-write-services.md)
- [`./PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md`](./PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md)
- [`./PR-18-legacy-service-retirement-and-regression-hardening.md`](./PR-18-legacy-service-retirement-and-regression-hardening.md)
- [`./PR-19-api-surface-audit-model-separation-and-public-read-dtos.md`](./PR-19-api-surface-audit-model-separation-and-public-read-dtos.md)
## Scope
- define a public packer action contract for asset capabilities
- let the packer decide which actions are available for a given asset at a given moment
- let Studio render the action section from packer-provided capabilities instead of local semantic rules
- keep the capability contract stable even if the packer later moves to FSM or another internal decision model
- deliver `AssetAction.REGISTER` as the first supported action end to end
- keep the service-only wave focused on active functionality
## Non-Goals
- no FSM implementation
- no full action family rollout in the first delivery
- no doctor/build/reconcile action surfaces
- no speculative future actions without packer support
- no frontend-local capability engine
## Execution Method
1. Add a public action capability contract in `prometeu-packer-api`, including:
- `AssetAction` enum
- capability response shape for asset actions
- request/response messages for reading action capabilities
2. Model the response so the packer can expose at least:
- `action`
- `enabled`
- `visible`
- optional `reason`
3. Add a runtime-backed read path in `prometeu-packer-v1` that resolves action capabilities from the current asset state.
4. Keep the internal decision logic inside the packer implementation. Studio must not reconstruct capability semantics.
5. Add a write path for `AssetAction.REGISTER`.
6. Expose `REGISTER` only when the asset capability resolver says it is allowed.
7. Render the `Actions` section in Studio from the returned capabilities.
8. Execute `REGISTER` from Studio through the packer write path and refresh the asset workspace after completion.
## Register-First Delivery Rules
The first delivered action is:
- `AssetAction.REGISTER`
Expected initial capability behavior:
- unregistered assets may expose `REGISTER`
- registered assets must not expose `REGISTER`
- Studio must only show the `Register` button when the packer returns that capability
Other future actions such as `MOVE`, `DELETE`, `INCLUDE_IN_BUILD`, `EXCLUDE_FROM_BUILD`, `ENABLE_PRELOAD`, `DISABLE_PRELOAD`, and `CHANGE_CONTRACT` are intentionally deferred from code delivery in this PR unless needed to support the contract shape.
## Acceptance Criteria
- the packer API exposes an asset action capability contract
- Studio reads action capabilities from the packer instead of deciding them locally
- the first delivered action is `AssetAction.REGISTER`
- `REGISTER` is available only for unregistered assets
- `REGISTER` is not shown for registered assets
- Studio can trigger `REGISTER` through the packer service path and refresh the workspace after success
- the capability contract remains valid if packer internals later migrate to FSM or another orchestration model
## Validation
- packer unit tests for capability resolution
- packer tests for `REGISTER` execution
- Studio tests or smoke tests proving that action buttons are rendered from service-provided capabilities
- end-to-end validation for unregistered asset -> register -> refresh -> registered asset
## Affected Artifacts
- `prometeu-packer/prometeu-packer-api/src/main/java/p/packer/**`
- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/**`
- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/**`
- `prometeu-studio/src/main/java/p/studio/**`
- `prometeu-studio/src/test/java/p/studio/**`

View File

@ -79,6 +79,7 @@ The current production track for the standalone `prometeu-packer` project is:
17. [`PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md`](./PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md) 17. [`PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md`](./PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md)
18. [`PR-18-legacy-service-retirement-and-regression-hardening.md`](./PR-18-legacy-service-retirement-and-regression-hardening.md) 18. [`PR-18-legacy-service-retirement-and-regression-hardening.md`](./PR-18-legacy-service-retirement-and-regression-hardening.md)
19. [`PR-19-api-surface-audit-model-separation-and-public-read-dtos.md`](./PR-19-api-surface-audit-model-separation-and-public-read-dtos.md) 19. [`PR-19-api-surface-audit-model-separation-and-public-read-dtos.md`](./PR-19-api-surface-audit-model-separation-and-public-read-dtos.md)
20. [`PR-20-asset-action-capabilities-and-register-first-delivery.md`](./PR-20-asset-action-capabilities-and-register-first-delivery.md)
Current wave discipline from `PR-11` onward: Current wave discipline from `PR-11` onward:
@ -89,4 +90,4 @@ Current wave discipline from `PR-11` onward:
Recommended dependency chain: Recommended dependency chain:
`PR-01 -> PR-02 -> PR-03 -> PR-04 -> PR-05 -> PR-06 -> PR-07 -> PR-08 -> PR-09 -> PR-10 -> PR-11 -> PR-12 -> PR-13 -> PR-14 -> PR-15 -> PR-16 -> PR-17 -> PR-18 -> PR-19` `PR-01 -> PR-02 -> PR-03 -> PR-04 -> PR-05 -> PR-06 -> PR-07 -> PR-08 -> PR-09 -> PR-10 -> PR-11 -> PR-12 -> PR-13 -> PR-14 -> PR-15 -> PR-16 -> PR-17 -> PR-18 -> PR-19 -> PR-20`

View File

@ -9,5 +9,9 @@ public interface PackerWorkspaceService {
GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request); GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request);
GetAssetActionsResult getAssetActions(GetAssetActionsRequest request);
CreateAssetResult createAsset(CreateAssetRequest request); CreateAssetResult createAsset(CreateAssetRequest request);
RegisterAssetResult registerAsset(RegisterAssetRequest request);
} }

View File

@ -0,0 +1,20 @@
package p.packer.dtos;
import p.packer.messages.assets.AssetAction;
import java.util.Objects;
public record PackerAssetActionAvailabilityDTO(
AssetAction action,
boolean enabled,
boolean visible,
String reason) {
public PackerAssetActionAvailabilityDTO {
Objects.requireNonNull(action, "action");
reason = reason == null ? null : reason.trim();
if (reason != null && reason.isBlank()) {
reason = null;
}
}
}

View File

@ -0,0 +1,13 @@
package p.packer.messages;
import java.util.Objects;
public record GetAssetActionsRequest(
PackerProjectContext project,
AssetReference assetReference) {
public GetAssetActionsRequest {
Objects.requireNonNull(project, "project");
Objects.requireNonNull(assetReference, "assetReference");
}
}

View File

@ -0,0 +1,24 @@
package p.packer.messages;
import p.packer.dtos.PackerAssetActionAvailabilityDTO;
import p.packer.dtos.PackerDiagnosticDTO;
import java.util.List;
import java.util.Objects;
public record GetAssetActionsResult(
PackerOperationStatus status,
String summary,
List<PackerAssetActionAvailabilityDTO> actions,
List<PackerDiagnosticDTO> diagnostics) {
public GetAssetActionsResult {
Objects.requireNonNull(status, "status");
summary = Objects.requireNonNull(summary, "summary").trim();
actions = List.copyOf(Objects.requireNonNull(actions, "actions"));
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
if (summary.isBlank()) {
throw new IllegalArgumentException("summary must not be blank");
}
}
}

View File

@ -0,0 +1,13 @@
package p.packer.messages;
import java.util.Objects;
public record RegisterAssetRequest(
PackerProjectContext project,
AssetReference assetReference) {
public RegisterAssetRequest {
Objects.requireNonNull(project, "project");
Objects.requireNonNull(assetReference, "assetReference");
}
}

View File

@ -0,0 +1,26 @@
package p.packer.messages;
import java.nio.file.Path;
import java.util.Objects;
public record RegisterAssetResult(
PackerOperationStatus status,
String summary,
AssetReference assetReference,
Path assetRoot,
Path manifestPath) {
public RegisterAssetResult {
Objects.requireNonNull(status, "status");
summary = Objects.requireNonNull(summary, "summary").trim();
if (summary.isBlank()) {
throw new IllegalArgumentException("summary must not be blank");
}
if (assetRoot != null) {
assetRoot = assetRoot.toAbsolutePath().normalize();
}
if (manifestPath != null) {
manifestPath = manifestPath.toAbsolutePath().normalize();
}
}
}

View File

@ -0,0 +1,5 @@
package p.packer.messages.assets;
public enum AssetAction {
REGISTER
}

View File

@ -26,11 +26,17 @@ public final class Packer implements Closeable {
final PackerAssetDeclarationParser declarationParser = new PackerAssetDeclarationParser(); final PackerAssetDeclarationParser declarationParser = new PackerAssetDeclarationParser();
final PackerRuntimeRegistry runtimeRegistry = new PackerRuntimeRegistry( final PackerRuntimeRegistry runtimeRegistry = new PackerRuntimeRegistry(
new PackerRuntimeLoader(workspaceFoundation, declarationParser)); new PackerRuntimeLoader(workspaceFoundation, declarationParser));
final PackerAssetDetailsService assetDetailsService = new PackerAssetDetailsService(runtimeRegistry); final PackerAssetReferenceResolver assetReferenceResolver = new PackerAssetReferenceResolver(workspaceFoundation.lookup());
final PackerAssetDetailsService assetDetailsService = new PackerAssetDetailsService(runtimeRegistry, assetReferenceResolver);
final PackerAssetActionReadService assetActionReadService = new PackerAssetActionReadService(
runtimeRegistry,
assetReferenceResolver,
workspaceFoundation.lookup());
final PackerProjectWriteCoordinator writeCoordinator = new PackerProjectWriteCoordinator(); final PackerProjectWriteCoordinator writeCoordinator = new PackerProjectWriteCoordinator();
return new Packer(new FileSystemPackerWorkspaceService( return new Packer(new FileSystemPackerWorkspaceService(
workspaceFoundation, workspaceFoundation,
assetDetailsService, assetDetailsService,
assetActionReadService,
runtimeRegistry, runtimeRegistry,
writeCoordinator, writeCoordinator,
resolvedEventSink), runtimeRegistry, writeCoordinator); resolvedEventSink), runtimeRegistry, writeCoordinator);

View File

@ -0,0 +1,20 @@
package p.packer.models;
import p.packer.messages.assets.AssetAction;
import java.util.Objects;
public record PackerAssetActionAvailability(
AssetAction action,
boolean enabled,
boolean visible,
String reason) {
public PackerAssetActionAvailability {
Objects.requireNonNull(action, "action");
reason = reason == null ? null : reason.trim();
if (reason != null && reason.isBlank()) {
reason = null;
}
}
}

View File

@ -0,0 +1,20 @@
package p.packer.models;
import java.util.List;
import java.util.Objects;
public record PackerRegisterAssetEvaluation(
PackerResolvedAssetReference resolved,
List<PackerDiagnostic> diagnostics,
boolean canRegister,
String reason) {
public PackerRegisterAssetEvaluation {
Objects.requireNonNull(resolved, "resolved");
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
reason = reason == null ? null : reason.trim();
if (reason != null && reason.isBlank()) {
reason = null;
}
}
}

View File

@ -0,0 +1,20 @@
package p.packer.models;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record PackerResolvedAssetReference(
Path assetRoot,
Optional<PackerRegistryEntry> registryEntry,
Optional<PackerRuntimeAsset> runtimeAsset,
List<PackerDiagnostic> diagnostics) {
public PackerResolvedAssetReference {
assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
registryEntry = Objects.requireNonNull(registryEntry, "registryEntry");
runtimeAsset = Objects.requireNonNull(runtimeAsset, "runtimeAsset");
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
}
}

View File

@ -5,6 +5,8 @@ import p.packer.messages.PackerOperationStatus;
import p.packer.messages.PackerProjectContext; import p.packer.messages.PackerProjectContext;
import p.packer.messages.assets.AssetFamilyCatalog; import p.packer.messages.assets.AssetFamilyCatalog;
import p.packer.messages.AssetReference; import p.packer.messages.AssetReference;
import p.packer.messages.GetAssetActionsRequest;
import p.packer.messages.GetAssetActionsResult;
import p.packer.messages.assets.OutputFormatCatalog; import p.packer.messages.assets.OutputFormatCatalog;
import p.packer.messages.assets.PackerAssetState; import p.packer.messages.assets.PackerAssetState;
import p.packer.messages.assets.PackerBuildParticipation; import p.packer.messages.assets.PackerBuildParticipation;
@ -21,9 +23,12 @@ import p.packer.messages.InitWorkspaceRequest;
import p.packer.messages.InitWorkspaceResult; import p.packer.messages.InitWorkspaceResult;
import p.packer.messages.ListAssetsRequest; import p.packer.messages.ListAssetsRequest;
import p.packer.messages.ListAssetsResult; import p.packer.messages.ListAssetsResult;
import p.packer.messages.RegisterAssetRequest;
import p.packer.messages.RegisterAssetResult;
import p.packer.PackerWorkspaceService; import p.packer.PackerWorkspaceService;
import p.packer.models.PackerAssetDeclarationParseResult; import p.packer.models.PackerAssetDeclarationParseResult;
import p.packer.models.PackerAssetIdentity; import p.packer.models.PackerAssetIdentity;
import p.packer.models.PackerRegisterAssetEvaluation;
import p.packer.models.PackerAssetSummary; import p.packer.models.PackerAssetSummary;
import p.packer.models.PackerDiagnostic; import p.packer.models.PackerDiagnostic;
import p.packer.models.PackerRegistryEntry; import p.packer.models.PackerRegistryEntry;
@ -41,6 +46,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private final PackerWorkspaceFoundation workspaceFoundation; private final PackerWorkspaceFoundation workspaceFoundation;
private final PackerAssetDetailsService detailsService; private final PackerAssetDetailsService detailsService;
private final PackerAssetActionReadService actionReadService;
private final PackerRuntimeRegistry runtimeRegistry; private final PackerRuntimeRegistry runtimeRegistry;
private final PackerProjectWriteCoordinator writeCoordinator; private final PackerProjectWriteCoordinator writeCoordinator;
private final PackerEventSink eventSink; private final PackerEventSink eventSink;
@ -48,11 +54,13 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
public FileSystemPackerWorkspaceService( public FileSystemPackerWorkspaceService(
PackerWorkspaceFoundation workspaceFoundation, PackerWorkspaceFoundation workspaceFoundation,
PackerAssetDetailsService detailsService, PackerAssetDetailsService detailsService,
PackerAssetActionReadService actionReadService,
PackerRuntimeRegistry runtimeRegistry, PackerRuntimeRegistry runtimeRegistry,
PackerProjectWriteCoordinator writeCoordinator, PackerProjectWriteCoordinator writeCoordinator,
PackerEventSink eventSink) { PackerEventSink eventSink) {
this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"); this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation");
this.detailsService = Objects.requireNonNull(detailsService, "detailsService"); this.detailsService = Objects.requireNonNull(detailsService, "detailsService");
this.actionReadService = Objects.requireNonNull(actionReadService, "actionReadService");
this.runtimeRegistry = Objects.requireNonNull(runtimeRegistry, "runtimeRegistry"); this.runtimeRegistry = Objects.requireNonNull(runtimeRegistry, "runtimeRegistry");
this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator"); this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator");
this.eventSink = Objects.requireNonNull(eventSink, "eventSink"); this.eventSink = Objects.requireNonNull(eventSink, "eventSink");
@ -133,6 +141,11 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return detailsService.getAssetDetails(request); return detailsService.getAssetDetails(request);
} }
@Override
public GetAssetActionsResult getAssetActions(GetAssetActionsRequest request) {
return actionReadService.getAssetActions(request);
}
@Override @Override
public CreateAssetResult createAsset(CreateAssetRequest request) { public CreateAssetResult createAsset(CreateAssetRequest request) {
final CreateAssetRequest safeRequest = Objects.requireNonNull(request, "request"); final CreateAssetRequest safeRequest = Objects.requireNonNull(request, "request");
@ -142,6 +155,14 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return writeCoordinator.execute(project, () -> createAssetInWriteLane(safeRequest, events)); return writeCoordinator.execute(project, () -> createAssetInWriteLane(safeRequest, events));
} }
@Override
public RegisterAssetResult registerAsset(RegisterAssetRequest request) {
final RegisterAssetRequest safeRequest = Objects.requireNonNull(request, "request");
final PackerProjectContext project = safeRequest.project();
final PackerOperationEventEmitter events = new PackerOperationEventEmitter(project, eventSink);
return writeCoordinator.execute(project, () -> registerAssetInWriteLane(safeRequest, events));
}
private CreateAssetResult createAssetInWriteLane( private CreateAssetResult createAssetInWriteLane(
CreateAssetRequest request, CreateAssetRequest request,
PackerOperationEventEmitter events) { PackerOperationEventEmitter events) {
@ -216,6 +237,50 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
} }
} }
private RegisterAssetResult registerAssetInWriteLane(
RegisterAssetRequest request,
PackerOperationEventEmitter events) {
final PackerProjectContext project = request.project();
workspaceFoundation.initWorkspace(new InitWorkspaceRequest(project));
final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot();
final PackerRegisterAssetEvaluation evaluation = actionReadService.evaluateRegister(snapshot, project, request.assetReference());
final Path assetRoot = evaluation.resolved().assetRoot();
final Path manifestPath = assetRoot.resolve("asset.json");
final String relativeAssetRoot = relativeAssetRoot(project, assetRoot);
if (!evaluation.canRegister()) {
final String summary = Objects.requireNonNullElse(evaluation.reason(), "Asset cannot be registered.");
return registerFailureResult(events, summary, assetRoot, manifestPath, List.of(relativeAssetRoot));
}
final var declaration = evaluation.resolved().runtimeAsset().orElseThrow().parsedDeclaration().declaration();
try {
final PackerRegistryState registry = workspaceFoundation.loadRegistry(project);
final PackerRegistryEntry entry = workspaceFoundation.registerExistingAsset(
project,
registry,
assetRoot,
declaration.assetUuid());
final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry);
workspaceFoundation.saveRegistry(project, updated);
runtimeRegistry.refresh(project);
final RegisterAssetResult result = new RegisterAssetResult(
PackerOperationStatus.SUCCESS,
"Asset registered: " + relativeAssetRoot,
AssetReference.forAssetId(entry.assetId()),
assetRoot,
manifestPath);
events.emit(PackerEventKind.ACTION_APPLIED, result.summary(), List.of(relativeAssetRoot));
return result;
} catch (RuntimeException exception) {
return registerFailureResult(
events,
"Unable to register asset: " + exception.getMessage(),
assetRoot,
manifestPath,
List.of(relativeAssetRoot));
}
}
private CreateAssetResult failureResult( private CreateAssetResult failureResult(
PackerOperationEventEmitter events, PackerOperationEventEmitter events,
String summary, String summary,
@ -232,6 +297,22 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return result; return result;
} }
private RegisterAssetResult registerFailureResult(
PackerOperationEventEmitter events,
String summary,
Path assetRoot,
Path manifestPath,
List<String> affectedAssets) {
final RegisterAssetResult result = new RegisterAssetResult(
PackerOperationStatus.FAILED,
summary,
null,
assetRoot,
manifestPath);
events.emit(PackerEventKind.ACTION_FAILED, result.summary(), affectedAssets);
return result;
}
private void writeManifest(Path manifestPath, CreateAssetRequest request, String assetUuid) throws IOException { private void writeManifest(Path manifestPath, CreateAssetRequest request, String assetUuid) throws IOException {
final Map<String, Object> manifest = new LinkedHashMap<>(); final Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("schema_version", 1); manifest.put("schema_version", 1);
@ -321,4 +402,8 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
.toString() .toString()
.replace('\\', '/')); .replace('\\', '/'));
} }
private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) {
return PackerWorkspacePaths.relativeAssetRoot(project, assetRoot).replace('\\', '/');
}
} }

View File

@ -0,0 +1,131 @@
package p.packer.services;
import p.packer.dtos.PackerAssetActionAvailabilityDTO;
import p.packer.messages.AssetReference;
import p.packer.messages.GetAssetActionsRequest;
import p.packer.messages.GetAssetActionsResult;
import p.packer.messages.PackerOperationStatus;
import p.packer.messages.PackerProjectContext;
import p.packer.messages.assets.AssetAction;
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.packer.models.PackerAssetActionAvailability;
import p.packer.models.PackerAssetDeclaration;
import p.packer.models.PackerAssetDeclarationParseResult;
import p.packer.models.PackerDiagnostic;
import p.packer.models.PackerRegisterAssetEvaluation;
import p.packer.models.PackerRegistryEntry;
import p.packer.models.PackerResolvedAssetReference;
import p.packer.models.PackerRuntimeAsset;
import p.packer.models.PackerRuntimeSnapshot;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public final class PackerAssetActionReadService {
private final PackerRuntimeRegistry runtimeRegistry;
private final PackerAssetReferenceResolver assetReferenceResolver;
private final PackerRegistryLookup registryLookup;
public PackerAssetActionReadService(
PackerRuntimeRegistry runtimeRegistry,
PackerAssetReferenceResolver assetReferenceResolver,
PackerRegistryLookup registryLookup) {
this.runtimeRegistry = Objects.requireNonNull(runtimeRegistry, "runtimeRegistry");
this.assetReferenceResolver = Objects.requireNonNull(assetReferenceResolver, "assetReferenceResolver");
this.registryLookup = Objects.requireNonNull(registryLookup, "registryLookup");
}
public GetAssetActionsResult getAssetActions(GetAssetActionsRequest request) {
final PackerProjectContext project = Objects.requireNonNull(request, "request").project();
final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot();
final PackerRegisterAssetEvaluation registerEvaluation = evaluateRegister(snapshot, project, request.assetReference());
final List<PackerAssetActionAvailability> actions = new ArrayList<>();
if (registerEvaluation.resolved().registryEntry().isEmpty()) {
actions.add(new PackerAssetActionAvailability(
AssetAction.REGISTER,
registerEvaluation.canRegister(),
true,
registerEvaluation.reason()));
}
final PackerOperationStatus status;
if (registerEvaluation.resolved().runtimeAsset().isEmpty()) {
status = PackerOperationStatus.FAILED;
} else if (registerEvaluation.diagnostics().stream().anyMatch(PackerDiagnostic::blocking)) {
status = PackerOperationStatus.PARTIAL;
} else {
status = PackerOperationStatus.SUCCESS;
}
return new GetAssetActionsResult(
status,
"Asset action capabilities resolved from runtime snapshot.",
PackerReadMessageMapper.toAssetActionAvailabilityDTOs(actions),
PackerReadMessageMapper.toDiagnosticDTOs(registerEvaluation.diagnostics()));
}
public PackerRegisterAssetEvaluation evaluateRegister(
PackerRuntimeSnapshot snapshot,
PackerProjectContext project,
AssetReference assetReference) {
final PackerResolvedAssetReference resolved = assetReferenceResolver.resolve(project, snapshot, assetReference);
final List<PackerDiagnostic> diagnostics = new ArrayList<>(resolved.diagnostics());
if (resolved.registryEntry().isPresent()) {
return new PackerRegisterAssetEvaluation(resolved, diagnostics, false, "Asset is already registered.");
}
if (resolved.runtimeAsset().isEmpty()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"asset.json was not found for the requested asset root.",
resolved.assetRoot().resolve("asset.json"),
true));
return new PackerRegisterAssetEvaluation(resolved, diagnostics, false, "asset.json was not found for the requested asset root.");
}
final PackerRuntimeAsset runtimeAsset = resolved.runtimeAsset().get();
final PackerAssetDeclarationParseResult parsed = runtimeAsset.parsedDeclaration();
diagnostics.addAll(parsed.diagnostics());
if (!parsed.valid()) {
return new PackerRegisterAssetEvaluation(resolved, diagnostics, false, firstBlockingReason(diagnostics, "Asset declaration is invalid."));
}
final PackerAssetDeclaration declaration = parsed.declaration();
final Optional<PackerRegistryEntry> sameUuidEntry = registryLookup.findByAssetUuid(snapshot.registry(), declaration.assetUuid());
if (sameUuidEntry.isPresent()) {
final Path registeredRoot = PackerWorkspacePaths.assetRoot(project, sameUuidEntry.get().root());
if (!registeredRoot.equals(resolved.assetRoot())) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'asset_uuid' is already registered to another asset root.",
runtimeAsset.manifestPath(),
true));
return new PackerRegisterAssetEvaluation(
resolved,
diagnostics,
false,
"Asset UUID is already registered by another asset.");
}
}
final Optional<PackerRegistryEntry> sameRootEntry = registryLookup.findByRoot(project, snapshot.registry(), resolved.assetRoot());
if (sameRootEntry.isPresent()) {
return new PackerRegisterAssetEvaluation(resolved, diagnostics, false, "Asset root is already registered.");
}
return new PackerRegisterAssetEvaluation(resolved, diagnostics, true, null);
}
private String firstBlockingReason(List<PackerDiagnostic> diagnostics, String fallback) {
return diagnostics.stream()
.filter(PackerDiagnostic::blocking)
.map(PackerDiagnostic::message)
.filter(message -> message != null && !message.isBlank())
.findFirst()
.orElse(fallback);
}
}

View File

@ -18,6 +18,7 @@ import p.packer.models.PackerAssetIdentity;
import p.packer.models.PackerAssetSummary; import p.packer.models.PackerAssetSummary;
import p.packer.models.PackerDiagnostic; import p.packer.models.PackerDiagnostic;
import p.packer.models.PackerRegistryEntry; import p.packer.models.PackerRegistryEntry;
import p.packer.models.PackerResolvedAssetReference;
import p.packer.models.PackerRuntimeAsset; import p.packer.models.PackerRuntimeAsset;
import p.packer.models.PackerRuntimeSnapshot; import p.packer.models.PackerRuntimeSnapshot;
@ -31,15 +32,19 @@ import java.util.Optional;
public final class PackerAssetDetailsService { public final class PackerAssetDetailsService {
private final PackerRuntimeRegistry runtimeRegistry; private final PackerRuntimeRegistry runtimeRegistry;
private final PackerAssetReferenceResolver assetReferenceResolver;
public PackerAssetDetailsService(PackerRuntimeRegistry runtimeRegistry) { public PackerAssetDetailsService(
PackerRuntimeRegistry runtimeRegistry,
PackerAssetReferenceResolver assetReferenceResolver) {
this.runtimeRegistry = Objects.requireNonNull(runtimeRegistry, "runtimeRegistry"); this.runtimeRegistry = Objects.requireNonNull(runtimeRegistry, "runtimeRegistry");
this.assetReferenceResolver = Objects.requireNonNull(assetReferenceResolver, "assetReferenceResolver");
} }
public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) { public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) {
final PackerProjectContext project = Objects.requireNonNull(request, "request").project(); final PackerProjectContext project = Objects.requireNonNull(request, "request").project();
final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot(); final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot();
final ResolvedAssetReference resolved = resolveReference(project, snapshot, request.assetReference()); final PackerResolvedAssetReference resolved = assetReferenceResolver.resolve(project, snapshot, request.assetReference());
final List<PackerDiagnostic> diagnostics = new ArrayList<>(resolved.diagnostics()); final List<PackerDiagnostic> diagnostics = new ArrayList<>(resolved.diagnostics());
if (resolved.runtimeAsset().isEmpty()) { if (resolved.runtimeAsset().isEmpty()) {
@ -100,7 +105,7 @@ public final class PackerAssetDetailsService {
private GetAssetDetailsResult failureResult( private GetAssetDetailsResult failureResult(
PackerProjectContext project, PackerProjectContext project,
AssetReference requestedReference, AssetReference requestedReference,
ResolvedAssetReference resolved, PackerResolvedAssetReference resolved,
List<PackerDiagnostic> diagnostics) { List<PackerDiagnostic> diagnostics) {
final PackerAssetState state = resolved.registryEntry().isPresent() final PackerAssetState state = resolved.registryEntry().isPresent()
? PackerAssetState.REGISTERED ? PackerAssetState.REGISTERED
@ -157,44 +162,6 @@ public final class PackerAssetDetailsService {
true)); true));
} }
private ResolvedAssetReference resolveReference(PackerProjectContext project, PackerRuntimeSnapshot snapshot, AssetReference assetReference) {
final PackerRegistryLookup lookup = new PackerRegistryLookup();
final String reference = Objects.requireNonNull(assetReference, "assetReference").rawValue();
final Optional<PackerRegistryEntry> byId = parseAssetId(reference).flatMap(assetId -> lookup.findByAssetId(snapshot.registry(), assetId));
if (byId.isPresent()) {
final Path assetRoot = PackerWorkspacePaths.assetRoot(project, byId.get().root());
return new ResolvedAssetReference(assetRoot, byId, findRuntimeAsset(snapshot, assetRoot), List.of());
}
final Optional<PackerRegistryEntry> byUuid = lookup.findByAssetUuid(snapshot.registry(), reference);
if (byUuid.isPresent()) {
final Path assetRoot = PackerWorkspacePaths.assetRoot(project, byUuid.get().root());
return new ResolvedAssetReference(assetRoot, byUuid, findRuntimeAsset(snapshot, assetRoot), List.of());
}
final Path candidateRoot = PackerWorkspacePaths.assetRoot(project, reference);
final Optional<PackerRuntimeAsset> runtimeAsset = findRuntimeAsset(snapshot, candidateRoot);
if (runtimeAsset.isPresent()) {
return new ResolvedAssetReference(candidateRoot, lookup.findByRoot(project, snapshot.registry(), candidateRoot), runtimeAsset, List.of());
}
final Optional<PackerRegistryEntry> registryEntry = lookup.findByRoot(project, snapshot.registry(), candidateRoot);
if (registryEntry.isPresent()) {
return new ResolvedAssetReference(candidateRoot, registryEntry, Optional.empty(), List.of());
}
return new ResolvedAssetReference(
candidateRoot,
Optional.empty(),
Optional.empty(),
List.of(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Requested asset reference could not be resolved.",
candidateRoot,
true)));
}
private AssetReference canonicalReference( private AssetReference canonicalReference(
PackerProjectContext project, PackerProjectContext project,
Path assetRoot, Path assetRoot,
@ -211,39 +178,10 @@ public final class PackerAssetDetailsService {
private AssetReference canonicalReferenceOrRequested( private AssetReference canonicalReferenceOrRequested(
PackerProjectContext project, PackerProjectContext project,
AssetReference requestedReference, AssetReference requestedReference,
ResolvedAssetReference resolved) { PackerResolvedAssetReference resolved) {
if (resolved.registryEntry().isPresent() || resolved.runtimeAsset().isPresent()) { if (resolved.registryEntry().isPresent() || resolved.runtimeAsset().isPresent()) {
return canonicalReference(project, resolved.assetRoot(), resolved.registryEntry()); return canonicalReference(project, resolved.assetRoot(), resolved.registryEntry());
} }
return requestedReference; return requestedReference;
} }
private Optional<Integer> parseAssetId(String reference) {
try {
return Optional.of(Integer.parseInt(reference.trim()));
} catch (NumberFormatException ignored) {
return Optional.empty();
}
}
private Optional<PackerRuntimeAsset> findRuntimeAsset(PackerRuntimeSnapshot snapshot, Path assetRoot) {
final Path normalizedRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
return snapshot.assets().stream()
.filter(candidate -> candidate.assetRoot().equals(normalizedRoot))
.findFirst();
}
private record ResolvedAssetReference(
Path assetRoot,
Optional<PackerRegistryEntry> registryEntry,
Optional<PackerRuntimeAsset> runtimeAsset,
List<PackerDiagnostic> diagnostics) {
private ResolvedAssetReference {
assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
registryEntry = Objects.requireNonNull(registryEntry, "registryEntry");
runtimeAsset = Objects.requireNonNull(runtimeAsset, "runtimeAsset");
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
}
}
} }

View File

@ -0,0 +1,88 @@
package p.packer.services;
import p.packer.messages.AssetReference;
import p.packer.messages.PackerProjectContext;
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.packer.models.PackerDiagnostic;
import p.packer.models.PackerRegistryEntry;
import p.packer.models.PackerResolvedAssetReference;
import p.packer.models.PackerRuntimeAsset;
import p.packer.models.PackerRuntimeSnapshot;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public final class PackerAssetReferenceResolver {
private final PackerRegistryLookup lookup;
public PackerAssetReferenceResolver() {
this(new PackerRegistryLookup());
}
public PackerAssetReferenceResolver(PackerRegistryLookup lookup) {
this.lookup = Objects.requireNonNull(lookup, "lookup");
}
public PackerResolvedAssetReference resolve(
PackerProjectContext project,
PackerRuntimeSnapshot snapshot,
AssetReference assetReference) {
final String reference = Objects.requireNonNull(assetReference, "assetReference").rawValue();
final Optional<PackerRegistryEntry> byId = parseAssetId(reference)
.flatMap(assetId -> lookup.findByAssetId(snapshot.registry(), assetId));
if (byId.isPresent()) {
final Path assetRoot = PackerWorkspacePaths.assetRoot(project, byId.get().root());
return new PackerResolvedAssetReference(assetRoot, byId, findRuntimeAsset(snapshot, assetRoot), List.of());
}
final Optional<PackerRegistryEntry> byUuid = lookup.findByAssetUuid(snapshot.registry(), reference);
if (byUuid.isPresent()) {
final Path assetRoot = PackerWorkspacePaths.assetRoot(project, byUuid.get().root());
return new PackerResolvedAssetReference(assetRoot, byUuid, findRuntimeAsset(snapshot, assetRoot), List.of());
}
final Path candidateRoot = PackerWorkspacePaths.assetRoot(project, reference);
final Optional<PackerRuntimeAsset> runtimeAsset = findRuntimeAsset(snapshot, candidateRoot);
if (runtimeAsset.isPresent()) {
return new PackerResolvedAssetReference(
candidateRoot,
lookup.findByRoot(project, snapshot.registry(), candidateRoot),
runtimeAsset,
List.of());
}
final Optional<PackerRegistryEntry> registryEntry = lookup.findByRoot(project, snapshot.registry(), candidateRoot);
if (registryEntry.isPresent()) {
return new PackerResolvedAssetReference(candidateRoot, registryEntry, Optional.empty(), List.of());
}
return new PackerResolvedAssetReference(
candidateRoot,
Optional.empty(),
Optional.empty(),
List.of(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Requested asset reference could not be resolved.",
candidateRoot,
true)));
}
private Optional<Integer> parseAssetId(String reference) {
try {
return Optional.of(Integer.parseInt(reference.trim()));
} catch (NumberFormatException ignored) {
return Optional.empty();
}
}
private Optional<PackerRuntimeAsset> findRuntimeAsset(PackerRuntimeSnapshot snapshot, Path assetRoot) {
final Path normalizedRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
return snapshot.assets().stream()
.filter(candidate -> candidate.assetRoot().equals(normalizedRoot))
.findFirst();
}
}

View File

@ -26,6 +26,30 @@ public final class PackerIdentityAllocator {
return new PackerRegistryEntry(state.nextAssetId(), UUID.randomUUID().toString(), relativeRoot); return new PackerRegistryEntry(state.nextAssetId(), UUID.randomUUID().toString(), relativeRoot);
} }
public PackerRegistryEntry registerExisting(
PackerProjectContext project,
PackerRegistryState state,
Path assetRoot,
String assetUuid) {
Objects.requireNonNull(project, "project");
Objects.requireNonNull(state, "state");
final Path normalizedAssetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, normalizedAssetRoot);
final String normalizedAssetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim();
if (normalizedAssetUuid.isBlank()) {
throw new IllegalArgumentException("assetUuid must not be blank");
}
final boolean alreadyRegisteredRoot = state.assets().stream().anyMatch(entry -> entry.root().equals(relativeRoot));
if (alreadyRegisteredRoot) {
throw new p.packer.exceptions.PackerRegistryException("Asset root is already registered: " + relativeRoot);
}
final boolean alreadyRegisteredUuid = state.assets().stream().anyMatch(entry -> entry.assetUuid().equals(normalizedAssetUuid));
if (alreadyRegisteredUuid) {
throw new p.packer.exceptions.PackerRegistryException("Asset UUID is already registered: " + normalizedAssetUuid);
}
return new PackerRegistryEntry(state.nextAssetId(), normalizedAssetUuid, relativeRoot);
}
public PackerRegistryState append(PackerRegistryState state, PackerRegistryEntry entry) { public PackerRegistryState append(PackerRegistryState state, PackerRegistryEntry entry) {
Objects.requireNonNull(state, "state"); Objects.requireNonNull(state, "state");
Objects.requireNonNull(entry, "entry"); Objects.requireNonNull(entry, "entry");

View File

@ -1,11 +1,13 @@
package p.packer.services; package p.packer.services;
import p.packer.dtos.PackerAssetDetailsDTO; import p.packer.dtos.PackerAssetDetailsDTO;
import p.packer.dtos.PackerAssetActionAvailabilityDTO;
import p.packer.dtos.PackerAssetIdentityDTO; import p.packer.dtos.PackerAssetIdentityDTO;
import p.packer.dtos.PackerAssetSummaryDTO; import p.packer.dtos.PackerAssetSummaryDTO;
import p.packer.dtos.PackerCodecConfigurationFieldDTO; import p.packer.dtos.PackerCodecConfigurationFieldDTO;
import p.packer.dtos.PackerDiagnosticDTO; import p.packer.dtos.PackerDiagnosticDTO;
import p.packer.messages.assets.OutputCodecCatalog; import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.models.PackerAssetActionAvailability;
import p.packer.models.PackerAssetDetails; import p.packer.models.PackerAssetDetails;
import p.packer.models.PackerAssetIdentity; import p.packer.models.PackerAssetIdentity;
import p.packer.models.PackerAssetSummary; import p.packer.models.PackerAssetSummary;
@ -50,6 +52,11 @@ public final class PackerReadMessageMapper {
return diagnostics.stream().map(PackerReadMessageMapper::toDiagnosticDTO).toList(); return diagnostics.stream().map(PackerReadMessageMapper::toDiagnosticDTO).toList();
} }
public static List<PackerAssetActionAvailabilityDTO> toAssetActionAvailabilityDTOs(
List<PackerAssetActionAvailability> actions) {
return actions.stream().map(PackerReadMessageMapper::toAssetActionAvailabilityDTO).toList();
}
private static PackerAssetIdentityDTO toAssetIdentityDTO(PackerAssetIdentity identity) { private static PackerAssetIdentityDTO toAssetIdentityDTO(PackerAssetIdentity identity) {
return new PackerAssetIdentityDTO( return new PackerAssetIdentityDTO(
identity.assetId(), identity.assetId(),
@ -85,4 +92,13 @@ public final class PackerReadMessageMapper {
diagnostic.evidencePath(), diagnostic.evidencePath(),
diagnostic.blocking()); diagnostic.blocking());
} }
private static PackerAssetActionAvailabilityDTO toAssetActionAvailabilityDTO(
PackerAssetActionAvailability action) {
return new PackerAssetActionAvailabilityDTO(
action.action(),
action.enabled(),
action.visible(),
action.reason());
}
} }

View File

@ -58,6 +58,14 @@ public final class PackerWorkspaceFoundation {
return identityAllocator.allocate(project, state, assetRoot); return identityAllocator.allocate(project, state, assetRoot);
} }
public PackerRegistryEntry registerExistingAsset(
PackerProjectContext project,
PackerRegistryState state,
java.nio.file.Path assetRoot,
String assetUuid) {
return identityAllocator.registerExisting(project, state, assetRoot, assetUuid);
}
public PackerRegistryState appendAllocatedEntry(PackerRegistryState state, PackerRegistryEntry entry) { public PackerRegistryState appendAllocatedEntry(PackerRegistryState state, PackerRegistryEntry entry) {
return identityAllocator.append(state, entry); return identityAllocator.append(state, entry);
} }

View File

@ -14,8 +14,12 @@ import p.packer.events.PackerEvent;
import p.packer.events.PackerEventKind; import p.packer.events.PackerEventKind;
import p.packer.messages.CreateAssetRequest; import p.packer.messages.CreateAssetRequest;
import p.packer.messages.CreateAssetResult; import p.packer.messages.CreateAssetResult;
import p.packer.messages.GetAssetActionsRequest;
import p.packer.messages.GetAssetDetailsRequest; import p.packer.messages.GetAssetDetailsRequest;
import p.packer.messages.ListAssetsRequest; import p.packer.messages.ListAssetsRequest;
import p.packer.messages.RegisterAssetRequest;
import p.packer.messages.RegisterAssetResult;
import p.packer.messages.assets.AssetAction;
import p.packer.testing.PackerFixtureLocator; import p.packer.testing.PackerFixtureLocator;
import java.nio.file.Files; import java.nio.file.Files;
@ -143,6 +147,143 @@ final class FileSystemPackerWorkspaceServiceTest {
assertTrue(detailsResult.diagnostics().isEmpty()); assertTrue(detailsResult.diagnostics().isEmpty());
} }
@Test
void exposesRegisterActionForValidUnregisteredAsset() throws Exception {
final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan-actions"));
final FileSystemPackerWorkspaceService service = service();
final var result = service.getAssetActions(new GetAssetActionsRequest(
project(projectRoot),
AssetReference.forRelativeAssetRoot("orphans/ui_sounds")));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
assertEquals(1, result.actions().size());
assertEquals(AssetAction.REGISTER, result.actions().getFirst().action());
assertTrue(result.actions().getFirst().visible());
assertTrue(result.actions().getFirst().enabled());
assertNull(result.actions().getFirst().reason());
}
@Test
void hidesRegisterActionForRegisteredAsset() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed-actions"));
final FileSystemPackerWorkspaceService service = service();
final var result = service.getAssetActions(new GetAssetActionsRequest(
project(projectRoot),
AssetReference.forAssetId(1)));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
assertTrue(result.actions().isEmpty());
}
@Test
void registersExistingUnregisteredAssetAndRefreshesRuntimeView() throws Exception {
final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("register-orphan"));
final List<PackerEvent> events = new CopyOnWriteArrayList<>();
final FileSystemPackerWorkspaceService service = service(events::add);
final RegisterAssetResult registerResult = service.registerAsset(new RegisterAssetRequest(
project(projectRoot),
AssetReference.forRelativeAssetRoot("orphans/ui_sounds")));
assertEquals(PackerOperationStatus.SUCCESS, registerResult.status());
assertNotNull(registerResult.assetReference());
assertTrue(registerResult.assetReference().isAssetIdReference());
final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
project(projectRoot),
registerResult.assetReference()));
assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status());
assertEquals(PackerAssetState.REGISTERED, detailsResult.details().summary().state());
assertEquals("orphan-uuid-1", detailsResult.details().summary().identity().assetUuid());
assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_APPLIED));
}
@Test
void blocksRegisterActionForInvalidDeclaration() throws Exception {
final Path projectRoot = copyFixture("workspaces/invalid-missing-fields", tempDir.resolve("invalid-actions"));
final FileSystemPackerWorkspaceService service = service();
final var result = service.getAssetActions(new GetAssetActionsRequest(
project(projectRoot),
AssetReference.forRelativeAssetRoot("bad")));
assertEquals(PackerOperationStatus.PARTIAL, result.status());
assertEquals(1, result.actions().size());
assertFalse(result.actions().getFirst().enabled());
assertNotNull(result.actions().getFirst().reason());
}
@Test
void rejectsRegisterWhenAssetUuidIsAlreadyRegisteredElsewhere() throws Exception {
final Path projectRoot = tempDir.resolve("duplicate-uuid");
Files.createDirectories(projectRoot.resolve("assets/registered/ui_atlas"));
Files.createDirectories(projectRoot.resolve("assets/orphans/ui_clone"));
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
Files.writeString(projectRoot.resolve("assets/registered/ui_atlas/asset.json"), """
{
"schema_version": 1,
"asset_uuid": "shared-uuid",
"name": "ui_atlas",
"type": "IMAGE/bank",
"inputs": {},
"output": {
"format": "TILES/indexed_v1",
"codec": "none"
},
"preload": {
"enabled": true
}
}
""");
Files.writeString(projectRoot.resolve("assets/orphans/ui_clone/asset.json"), """
{
"schema_version": 1,
"asset_uuid": "shared-uuid",
"name": "ui_clone",
"type": "IMAGE/bank",
"inputs": {},
"output": {
"format": "TILES/indexed_v1",
"codec": "none"
},
"preload": {
"enabled": false
}
}
""");
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """
{
"schema_version": 1,
"next_asset_id": 2,
"assets": [
{
"asset_id": 1,
"asset_uuid": "shared-uuid",
"root": "registered/ui_atlas",
"included_in_build": true
}
]
}
""");
final FileSystemPackerWorkspaceService service = service();
final var actions = service.getAssetActions(new GetAssetActionsRequest(
project(projectRoot),
AssetReference.forRelativeAssetRoot("orphans/ui_clone")));
assertEquals(1, actions.actions().size());
assertFalse(actions.actions().getFirst().enabled());
assertNotNull(actions.actions().getFirst().reason());
final RegisterAssetResult registerResult = service.registerAsset(new RegisterAssetRequest(
project(projectRoot),
AssetReference.forRelativeAssetRoot("orphans/ui_clone")));
assertEquals(PackerOperationStatus.FAILED, registerResult.status());
assertNull(registerResult.assetReference());
}
@Test @Test
void rejectsUnsupportedFormatForSelectedFamily() { void rejectsUnsupportedFormatForSelectedFamily() {
final Path projectRoot = tempDir.resolve("unsupported"); final Path projectRoot = tempDir.resolve("unsupported");
@ -208,9 +349,17 @@ final class FileSystemPackerWorkspaceServiceTest {
final var foundation = new p.packer.services.PackerWorkspaceFoundation(); final var foundation = new p.packer.services.PackerWorkspaceFoundation();
final var parser = new p.packer.services.PackerAssetDeclarationParser(); final var parser = new p.packer.services.PackerAssetDeclarationParser();
final var runtimeRegistry = new p.packer.services.PackerRuntimeRegistry(new p.packer.services.PackerRuntimeLoader(foundation, parser)); final var runtimeRegistry = new p.packer.services.PackerRuntimeRegistry(new p.packer.services.PackerRuntimeLoader(foundation, parser));
final var detailsService = new p.packer.services.PackerAssetDetailsService(runtimeRegistry); final var resolver = new p.packer.services.PackerAssetReferenceResolver(foundation.lookup());
final var detailsService = new p.packer.services.PackerAssetDetailsService(runtimeRegistry, resolver);
final var actionReadService = new p.packer.services.PackerAssetActionReadService(runtimeRegistry, resolver, foundation.lookup());
final var writeCoordinator = new p.packer.services.PackerProjectWriteCoordinator(); final var writeCoordinator = new p.packer.services.PackerProjectWriteCoordinator();
return new FileSystemPackerWorkspaceService(foundation, detailsService, runtimeRegistry, writeCoordinator, eventSink); return new FileSystemPackerWorkspaceService(
foundation,
detailsService,
actionReadService,
runtimeRegistry,
writeCoordinator,
eventSink);
} }
private Path copyFixture(String relativePath, Path targetRoot) throws Exception { private Path copyFixture(String relativePath, Path targetRoot) throws Exception {

View File

@ -88,7 +88,9 @@ final class PackerAssetDetailsServiceTest {
private PackerAssetDetailsService service() { private PackerAssetDetailsService service() {
final var foundation = new p.packer.services.PackerWorkspaceFoundation(); final var foundation = new p.packer.services.PackerWorkspaceFoundation();
final var parser = new PackerAssetDeclarationParser(); final var parser = new PackerAssetDeclarationParser();
return new PackerAssetDetailsService(new PackerRuntimeRegistry(new PackerRuntimeLoader(foundation, parser))); final var runtimeRegistry = new PackerRuntimeRegistry(new PackerRuntimeLoader(foundation, parser));
final var resolver = new PackerAssetReferenceResolver(foundation.lookup());
return new PackerAssetDetailsService(runtimeRegistry, resolver);
} }
private Path copyFixture(String relativePath, Path targetRoot) throws Exception { private Path copyFixture(String relativePath, Path targetRoot) throws Exception {

View File

@ -100,6 +100,7 @@ public enum I18n {
ASSETS_SECTION_INPUTS_PREVIEW("assets.section.inputsPreview"), ASSETS_SECTION_INPUTS_PREVIEW("assets.section.inputsPreview"),
ASSETS_SECTION_DIAGNOSTICS("assets.section.diagnostics"), ASSETS_SECTION_DIAGNOSTICS("assets.section.diagnostics"),
ASSETS_SECTION_ACTIONS("assets.section.actions"), ASSETS_SECTION_ACTIONS("assets.section.actions"),
ASSETS_ACTIONS_EMPTY("assets.actions.empty"),
ASSETS_ACTION_REGISTER("assets.action.register"), ASSETS_ACTION_REGISTER("assets.action.register"),
ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"), ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"),
ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"), ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"),

View File

@ -1,20 +1,28 @@
package p.studio.workspaces.assets.details; package p.studio.workspaces.assets.details;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane; import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import p.packer.messages.AssetReference; import p.packer.messages.AssetReference;
import p.packer.dtos.PackerAssetActionAvailabilityDTO;
import p.packer.dtos.PackerAssetDetailsDTO; import p.packer.dtos.PackerAssetDetailsDTO;
import p.packer.messages.GetAssetDetailsRequest; import p.packer.messages.GetAssetDetailsRequest;
import p.packer.messages.GetAssetActionsRequest;
import p.packer.messages.RegisterAssetRequest;
import p.packer.messages.RegisterAssetResult;
import p.packer.messages.assets.AssetAction;
import p.studio.Container; import p.studio.Container;
import p.studio.events.StudioWorkspaceEventBus; import p.studio.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n; import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.details.contract.AssetDetailsContractControl; import p.studio.workspaces.assets.details.contract.AssetDetailsContractControl;
import p.studio.workspaces.assets.details.summary.AssetDetailsSummaryControl; import p.studio.workspaces.assets.details.summary.AssetDetailsSummaryControl;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetAction;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails; import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsStatus; import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsStatus;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
@ -46,6 +54,8 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
private AssetWorkspaceDetailsViewState viewState = AssetDetailsViewStateFactory.empty(); private AssetWorkspaceDetailsViewState viewState = AssetDetailsViewStateFactory.empty();
private boolean readyMounted; private boolean readyMounted;
private boolean actionRunning;
private String actionFeedbackMessage;
public AssetDetailsControl( public AssetDetailsControl(
ProjectReference projectReference, ProjectReference projectReference,
@ -109,12 +119,16 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
private void loadSelection(AssetReference assetReference) { private void loadSelection(AssetReference assetReference) {
final long generation = loadGeneration.incrementAndGet(); final long generation = loadGeneration.incrementAndGet();
actionRunning = false;
actionFeedbackMessage = null;
publishViewState(AssetDetailsViewStateFactory.loading(assetReference)); publishViewState(AssetDetailsViewStateFactory.loading(assetReference));
Container.backgroundTasks().submit(() -> loadDetails(generation, assetReference)); Container.backgroundTasks().submit(() -> loadDetails(generation, assetReference));
} }
private void clearSelection() { private void clearSelection() {
loadGeneration.incrementAndGet(); loadGeneration.incrementAndGet();
actionRunning = false;
actionFeedbackMessage = null;
publishViewState(AssetDetailsViewStateFactory.empty()); publishViewState(AssetDetailsViewStateFactory.empty());
} }
@ -128,12 +142,15 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
private void loadDetails(long generation, AssetReference assetReference) { private void loadDetails(long generation, AssetReference assetReference) {
try { try {
final var response = Container.packer() final var workspaceService = Container.packer().workspaceService();
.workspaceService() final var response = workspaceService
.getAssetDetails(new GetAssetDetailsRequest( .getAssetDetails(new GetAssetDetailsRequest(
projectReference.toPackerProjectContext(), projectReference.toPackerProjectContext(),
assetReference)); assetReference));
final AssetWorkspaceAssetDetails details = mapDetails(response.details()); final var actionsResponse = workspaceService.getAssetActions(new GetAssetActionsRequest(
projectReference.toPackerProjectContext(),
assetReference));
final AssetWorkspaceAssetDetails details = mapDetails(response.details(), actionsResponse.actions());
Platform.runLater(() -> applyLoadedDetails(generation, assetReference, details)); Platform.runLater(() -> applyLoadedDetails(generation, assetReference, details));
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
Platform.runLater(() -> applyLoadFailure(generation, assetReference, exception)); Platform.runLater(() -> applyLoadFailure(generation, assetReference, exception));
@ -175,18 +192,21 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
readyMounted = false; readyMounted = false;
setDetailsTitle(null); setDetailsTitle(null);
setWorkspaceSummary(Container.i18n().text(I18n.ASSETS_SUMMARY_EMPTY)); setWorkspaceSummary(Container.i18n().text(I18n.ASSETS_SUMMARY_EMPTY));
renderActions();
detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_NO_SELECTION))); detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_NO_SELECTION)));
} }
case LOADING -> { case LOADING -> {
readyMounted = false; readyMounted = false;
setDetailsTitle(null); setDetailsTitle(null);
setWorkspaceSummary(Container.i18n().text(I18n.ASSETS_SUMMARY_LOADING)); setWorkspaceSummary(Container.i18n().text(I18n.ASSETS_SUMMARY_LOADING));
renderActions();
detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING))); detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)));
} }
case ERROR -> { case ERROR -> {
readyMounted = false; readyMounted = false;
setDetailsTitle(null); setDetailsTitle(null);
setWorkspaceSummary(Container.i18n().text(I18n.ASSETS_SUMMARY_ERROR)); setWorkspaceSummary(Container.i18n().text(I18n.ASSETS_SUMMARY_ERROR));
renderActions();
detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage( detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(
Objects.requireNonNullElse(viewState.detailsErrorMessage(), Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY)))); Objects.requireNonNullElse(viewState.detailsErrorMessage(), Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))));
} }
@ -195,12 +215,14 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
readyMounted = false; readyMounted = false;
setDetailsTitle(null); setDetailsTitle(null);
setWorkspaceSummary(null); setWorkspaceSummary(null);
renderActions();
detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_NO_SELECTION))); detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_NO_SELECTION)));
return; return;
} }
setDetailsTitle(viewState.selectedAssetDetails().summary().assetName()); setDetailsTitle(viewState.selectedAssetDetails().summary().assetName());
setWorkspaceSummary(null); setWorkspaceSummary(null);
renderActions();
if (!readyMounted) { if (!readyMounted) {
readyMounted = true; readyMounted = true;
detailsContent.getChildren().setAll(primarySectionsRow, contractControl); detailsContent.getChildren().setAll(primarySectionsRow, contractControl);
@ -244,9 +266,93 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
workspaceSummaryLabel.setManaged(visible); workspaceSummaryLabel.setManaged(visible);
} }
private AssetWorkspaceAssetDetails mapDetails(PackerAssetDetailsDTO details) { private void renderActions() {
actionsContent.getChildren().setAll(buildActionNodes());
}
private java.util.List<Node> buildActionNodes() {
final java.util.List<Node> nodes = new java.util.ArrayList<>();
if (actionFeedbackMessage != null && !actionFeedbackMessage.isBlank()) {
nodes.add(AssetDetailsUiSupport.createSectionMessage(actionFeedbackMessage));
}
if (viewState.selectedAssetDetails() == null) {
nodes.add(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_ACTIONS_EMPTY)));
return nodes;
}
final var visibleActions = viewState.selectedAssetDetails().actions().stream()
.filter(AssetWorkspaceAssetAction::visible)
.toList();
if (visibleActions.isEmpty()) {
nodes.add(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_ACTIONS_EMPTY)));
return nodes;
}
for (AssetWorkspaceAssetAction action : visibleActions) {
final Button button = AssetDetailsUiSupport.createActionButton(AssetDetailsUiSupport.actionLabel(action.action()));
button.setDisable(actionRunning || !action.enabled());
button.setOnAction(ignored -> executeAction(action));
nodes.add(button);
if (action.reason() != null) {
nodes.add(AssetDetailsUiSupport.createSectionMessage(action.reason()));
}
}
return nodes;
}
private void executeAction(AssetWorkspaceAssetAction action) {
if (actionRunning || viewState.selectedAssetReference() == null) {
return;
}
actionRunning = true;
actionFeedbackMessage = null;
renderActions();
switch (action.action()) {
case REGISTER -> Container.backgroundTasks().submit(() -> registerSelectedAsset(viewState.selectedAssetReference()));
}
}
private void registerSelectedAsset(AssetReference assetReference) {
try {
final RegisterAssetResult result = Container.packer().workspaceService().registerAsset(new RegisterAssetRequest(
projectReference.toPackerProjectContext(),
assetReference));
Platform.runLater(() -> applyRegisterResult(result));
} catch (RuntimeException exception) {
Platform.runLater(() -> applyRegisterFailure(exception));
}
}
private void applyRegisterResult(RegisterAssetResult result) {
actionRunning = false;
if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) {
actionFeedbackMessage = null;
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(result.assetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(result.assetReference()));
return;
}
actionFeedbackMessage = Objects.requireNonNullElse(result.summary(), "Unable to register asset.");
renderActions();
}
private void applyRegisterFailure(RuntimeException exception) {
actionRunning = false;
actionFeedbackMessage = exception.getMessage() == null || exception.getMessage().isBlank()
? "Unable to register asset."
: exception.getMessage();
renderActions();
}
private AssetWorkspaceAssetDetails mapDetails(
PackerAssetDetailsDTO details,
java.util.List<PackerAssetActionAvailabilityDTO> actions) {
return new AssetWorkspaceAssetDetails( return new AssetWorkspaceAssetDetails(
AssetListPackerMappings.mapSummary(details.summary()), AssetListPackerMappings.mapSummary(details.summary()),
actions.stream()
.map(action -> new AssetWorkspaceAssetAction(
action.action(),
action.enabled(),
action.visible(),
action.reason()))
.toList(),
details.outputFormat(), details.outputFormat(),
details.outputCodec(), details.outputCodec(),
details.availableOutputCodecs(), details.availableOutputCodecs(),

View File

@ -2,10 +2,12 @@ package p.studio.workspaces.assets.details;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import p.packer.messages.assets.AssetAction;
import p.packer.messages.assets.AssetFamilyCatalog; import p.packer.messages.assets.AssetFamilyCatalog;
import p.studio.Container; import p.studio.Container;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
@ -58,6 +60,13 @@ public final class AssetDetailsUiSupport {
return chip; return chip;
} }
public static Button createActionButton(String text) {
final Button button = new Button(text);
button.getStyleClass().addAll("studio-button", "studio-button-primary", "assets-details-action-button");
button.setMaxWidth(Double.MAX_VALUE);
return button;
}
public static String registrationLabel(AssetWorkspaceAssetState state) { public static String registrationLabel(AssetWorkspaceAssetState state) {
return switch (state) { return switch (state) {
case REGISTERED -> Container.i18n().text(I18n.ASSETS_VALUE_REGISTERED); case REGISTERED -> Container.i18n().text(I18n.ASSETS_VALUE_REGISTERED);
@ -96,6 +105,12 @@ public final class AssetDetailsUiSupport {
}; };
} }
public static String actionLabel(AssetAction action) {
return switch (action) {
case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER);
};
}
public static String registrationChipTone(AssetWorkspaceAssetState state) { public static String registrationChipTone(AssetWorkspaceAssetState state) {
return switch (state) { return switch (state) {
case REGISTERED -> "assets-details-chip-registered"; case REGISTERED -> "assets-details-chip-registered";

View File

@ -0,0 +1,20 @@
package p.studio.workspaces.assets.messages;
import p.packer.messages.assets.AssetAction;
import java.util.Objects;
public record AssetWorkspaceAssetAction(
AssetAction action,
boolean enabled,
boolean visible,
String reason) {
public AssetWorkspaceAssetAction {
Objects.requireNonNull(action, "action");
reason = reason == null ? null : reason.trim();
if (reason != null && reason.isBlank()) {
reason = null;
}
}
}

View File

@ -10,6 +10,7 @@ import java.util.Objects;
public record AssetWorkspaceAssetDetails( public record AssetWorkspaceAssetDetails(
AssetWorkspaceAssetSummary summary, AssetWorkspaceAssetSummary summary,
List<AssetWorkspaceAssetAction> actions,
String outputFormat, String outputFormat,
OutputCodecCatalog outputCodec, OutputCodecCatalog outputCodec,
List<OutputCodecCatalog> availableOutputCodecs, List<OutputCodecCatalog> availableOutputCodecs,
@ -18,6 +19,7 @@ public record AssetWorkspaceAssetDetails(
public AssetWorkspaceAssetDetails { public AssetWorkspaceAssetDetails {
Objects.requireNonNull(summary, "summary"); Objects.requireNonNull(summary, "summary");
actions = List.copyOf(Objects.requireNonNull(actions, "actions"));
outputFormat = Objects.requireNonNullElse(outputFormat, "unknown"); outputFormat = Objects.requireNonNullElse(outputFormat, "unknown");
outputCodec = Objects.requireNonNullElse(outputCodec, OutputCodecCatalog.UNKNOWN); outputCodec = Objects.requireNonNullElse(outputCodec, OutputCodecCatalog.UNKNOWN);
availableOutputCodecs = List.copyOf(Objects.requireNonNull(availableOutputCodecs, "availableOutputCodecs")); availableOutputCodecs = List.copyOf(Objects.requireNonNull(availableOutputCodecs, "availableOutputCodecs"));

View File

@ -90,6 +90,7 @@ assets.subsection.codecConfiguration=Codec Configuration
assets.section.inputsPreview=Inputs / Preview assets.section.inputsPreview=Inputs / Preview
assets.section.diagnostics=Diagnostics assets.section.diagnostics=Diagnostics
assets.section.actions=Actions assets.section.actions=Actions
assets.actions.empty=No actions available for this asset.
assets.action.register=Register assets.action.register=Register
assets.action.includeInBuild=Include In Build assets.action.includeInBuild=Include In Build
assets.action.excludeFromBuild=Exclude From Build assets.action.excludeFromBuild=Exclude From Build

View File

@ -547,6 +547,10 @@
-fx-spacing: 10; -fx-spacing: 10;
} }
.assets-details-action-button {
-fx-max-width: Infinity;
}
.assets-details-section { .assets-details-section {
-fx-background-color: #11151b; -fx-background-color: #11151b;
-fx-background-radius: 12; -fx-background-radius: 12;