implements packer PR-04 workspace scan and studio read adapter

This commit is contained in:
bQUARKz 2026-03-11 17:43:12 +00:00
parent 12f2311d08
commit 924ab587e8
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
11 changed files with 486 additions and 1 deletions

View File

@ -0,0 +1,151 @@
package p.packer.workspace;
import p.packer.api.PackerOperationClass;
import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerAssetIdentity;
import p.packer.api.assets.PackerAssetState;
import p.packer.api.assets.PackerAssetSummary;
import p.packer.api.diagnostics.PackerDiagnostic;
import p.packer.api.diagnostics.PackerDiagnosticCategory;
import p.packer.api.diagnostics.PackerDiagnosticSeverity;
import p.packer.api.workspace.GetAssetDetailsRequest;
import p.packer.api.workspace.GetAssetDetailsResult;
import p.packer.api.workspace.InitWorkspaceRequest;
import p.packer.api.workspace.InitWorkspaceResult;
import p.packer.api.workspace.ListAssetsRequest;
import p.packer.api.workspace.ListAssetsResult;
import p.packer.api.workspace.PackerWorkspaceService;
import p.packer.declarations.PackerAssetDeclarationParseResult;
import p.packer.declarations.PackerAssetDeclarationParser;
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.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService {
private final PackerWorkspaceFoundation workspaceFoundation;
private final PackerAssetDeclarationParser parser;
private final PackerAssetDetailsService detailsService;
public FileSystemPackerWorkspaceService() {
this(new PackerWorkspaceFoundation(), new PackerAssetDeclarationParser());
}
public FileSystemPackerWorkspaceService(
PackerWorkspaceFoundation workspaceFoundation,
PackerAssetDeclarationParser parser) {
this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation");
this.parser = Objects.requireNonNull(parser, "parser");
this.detailsService = new PackerAssetDetailsService(workspaceFoundation, parser);
}
@Override
public PackerOperationClass operationClass() {
return PackerOperationClass.READ_ONLY;
}
@Override
public InitWorkspaceResult initWorkspace(InitWorkspaceRequest request) {
return workspaceFoundation.initWorkspace(request);
}
@Override
public ListAssetsResult listAssets(ListAssetsRequest request) {
final PackerProjectContext project = Objects.requireNonNull(request, "request").project();
final Path assetsRoot = PackerWorkspacePaths.assetsRoot(project);
final PackerRegistryState registry = workspaceFoundation.loadRegistry(project);
final Map<Path, PackerRegistryEntry> registryByRoot = new HashMap<>();
for (PackerRegistryEntry entry : registry.assets()) {
registryByRoot.put(PackerWorkspacePaths.assetRoot(project, entry.root()), entry);
}
final List<PackerAssetSummary> assets = new ArrayList<>();
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
final Set<Path> discoveredRoots = new HashSet<>();
if (Files.isDirectory(assetsRoot)) {
try (Stream<Path> paths = Files.find(assetsRoot, Integer.MAX_VALUE, (path, attrs) ->
attrs.isRegularFile() && path.getFileName().toString().equals("asset.json"))) {
paths.forEach(assetManifestPath -> {
final Path assetRoot = assetManifestPath.getParent().toAbsolutePath().normalize();
discoveredRoots.add(assetRoot);
final PackerRegistryEntry registryEntry = registryByRoot.get(assetRoot);
final PackerAssetDeclarationParseResult parsed = parser.parse(assetManifestPath);
diagnostics.addAll(parsed.diagnostics());
assets.add(buildSummary(assetRoot, registryEntry, parsed));
});
} catch (IOException exception) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Unable to scan assets workspace: " + exception.getMessage(),
assetsRoot,
true));
}
}
for (PackerRegistryEntry entry : registry.assets()) {
final Path registeredRoot = PackerWorkspacePaths.assetRoot(project, entry.root());
if (!discoveredRoots.contains(registeredRoot)) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Registered asset root is missing asset.json: " + entry.root(),
registeredRoot.resolve("asset.json"),
true));
}
}
assets.sort(Comparator
.comparing((PackerAssetSummary asset) -> asset.identity().assetRoot().toString(), String.CASE_INSENSITIVE_ORDER)
.thenComparing(summary -> summary.identity().assetName(), String.CASE_INSENSITIVE_ORDER));
final PackerOperationStatus status = diagnostics.stream().anyMatch(PackerDiagnostic::blocking)
? PackerOperationStatus.PARTIAL
: PackerOperationStatus.SUCCESS;
return new ListAssetsResult(
status,
"Packer asset snapshot ready.",
assets,
diagnostics);
}
@Override
public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) {
return detailsService.getAssetDetails(request);
}
private PackerAssetSummary buildSummary(
Path assetRoot,
PackerRegistryEntry registryEntry,
PackerAssetDeclarationParseResult parsed) {
final String assetName = parsed.declaration() != null
? parsed.declaration().name()
: assetRoot.getFileName().toString();
final String assetFamily = parsed.declaration() != null
? parsed.declaration().type()
: "unknown";
final boolean preload = parsed.declaration() != null && parsed.declaration().preloadEnabled();
final boolean hasDiagnostics = !parsed.diagnostics().isEmpty();
final PackerAssetState state = parsed.valid()
? (registryEntry == null ? PackerAssetState.ORPHAN : PackerAssetState.MANAGED)
: PackerAssetState.INVALID;
return new PackerAssetSummary(
new PackerAssetIdentity(
registryEntry == null ? null : registryEntry.assetId(),
registryEntry == null ? null : registryEntry.assetUuid(),
assetName,
assetRoot),
state,
assetFamily,
preload,
hasDiagnostics);
}
}

View File

@ -0,0 +1,76 @@
package p.packer.workspace;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerAssetState;
import p.packer.api.workspace.ListAssetsRequest;
import p.packer.testing.PackerFixtureLocator;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import static org.junit.jupiter.api.Assertions.*;
final class FileSystemPackerWorkspaceServiceTest {
@TempDir
Path tempDir;
@Test
void listsManagedAndOrphanAssetsFromWorkspaceScan() throws Exception {
final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("mixed"));
final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService();
final var result = service.listAssets(new ListAssetsRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
assertEquals(2, result.assets().size());
assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.MANAGED));
assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.ORPHAN));
}
@Test
void surfacesMissingRegisteredRootAsStructuralDiagnostic() throws Exception {
final Path projectRoot = copyFixture("workspaces/read-missing-root", tempDir.resolve("missing-root"));
final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService();
final var result = service.listAssets(new ListAssetsRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.PARTIAL, result.status());
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("missing asset.json")));
}
@Test
void includesInvalidDeclarationsInSnapshotWithDiagnostics() throws Exception {
final Path projectRoot = copyFixture("workspaces/read-invalid", tempDir.resolve("invalid"));
final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService();
final var result = service.listAssets(new ListAssetsRequest(project(projectRoot)));
assertEquals(1, result.assets().size());
assertEquals(PackerAssetState.INVALID, result.assets().getFirst().state());
assertTrue(result.assets().getFirst().hasDiagnostics());
}
private PackerProjectContext project(Path root) {
return new PackerProjectContext("main", root);
}
private Path copyFixture(String relativePath, Path targetRoot) throws Exception {
final Path sourceRoot = PackerFixtureLocator.fixtureRoot(relativePath);
try (var stream = Files.walk(sourceRoot)) {
for (Path source : stream.sorted(Comparator.naturalOrder()).toList()) {
final Path target = targetRoot.resolve(sourceRoot.relativize(source).toString());
if (Files.isDirectory(source)) {
Files.createDirectories(target);
} else {
Files.createDirectories(target.getParent());
Files.copy(source, target);
}
}
}
return targetRoot;
}
}

View File

@ -0,0 +1,11 @@
{
"schema_version": 1,
"type": "image_bank",
"inputs": "wrong",
"output": {
"codec": ""
},
"preload": {
"enabled": true
}
}

View File

@ -0,0 +1,11 @@
{
"schema_version": 1,
"next_asset_id": 2,
"assets": [
{
"asset_id": 1,
"asset_uuid": "uuid-1",
"root": "missing/atlas"
}
]
}

View File

@ -0,0 +1,11 @@
{
"schema_version": 1,
"next_asset_id": 2,
"assets": [
{
"asset_id": 1,
"asset_uuid": "uuid-1",
"root": "ui/atlas"
}
]
}

View File

@ -0,0 +1,15 @@
{
"schema_version": 1,
"name": "ui_sounds",
"type": "sound_bank",
"inputs": {
"sources": ["confirm.wav"]
},
"output": {
"format": "SOUND/bank_v1",
"codec": "RAW"
},
"preload": {
"enabled": false
}
}

View File

@ -0,0 +1,15 @@
{
"schema_version": 1,
"name": "ui_atlas",
"type": "image_bank",
"inputs": {
"sprites": ["sprites/confirm.png"]
},
"output": {
"format": "TILES/indexed_v1",
"codec": "RAW"
},
"preload": {
"enabled": true
}
}

View File

@ -5,6 +5,7 @@ plugins {
dependencies { dependencies {
implementation(project(":prometeu-infra")) implementation(project(":prometeu-infra"))
implementation(project(":prometeu-packer"))
implementation(project(":prometeu-compiler:prometeu-compiler-core")) implementation(project(":prometeu-compiler:prometeu-compiler-core"))
implementation(project(":prometeu-compiler:prometeu-build-pipeline")) implementation(project(":prometeu-compiler:prometeu-build-pipeline"))
implementation(project(":prometeu-compiler:prometeu-frontend-registry")) implementation(project(":prometeu-compiler:prometeu-frontend-registry"))

View File

@ -56,7 +56,7 @@ public final class AssetWorkspace implements Workspace {
private String searchQuery = ""; private String searchQuery = "";
public AssetWorkspace(ProjectReference projectReference) { public AssetWorkspace(ProjectReference projectReference) {
this(projectReference, new FileSystemAssetWorkspaceService(), new FileSystemAssetWorkspaceMutationService()); this(projectReference, new PackerBackedAssetWorkspaceService(), new FileSystemAssetWorkspaceMutationService());
} }
public AssetWorkspace( public AssetWorkspace(

View File

@ -0,0 +1,101 @@
package p.studio.workspaces.assets;
import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerAssetDetails;
import p.packer.api.assets.PackerAssetState;
import p.packer.api.assets.PackerAssetSummary;
import p.packer.api.diagnostics.PackerDiagnostic;
import p.packer.api.diagnostics.PackerDiagnosticSeverity;
import p.packer.api.workspace.GetAssetDetailsRequest;
import p.packer.api.workspace.ListAssetsRequest;
import p.packer.workspace.FileSystemPackerWorkspaceService;
import p.studio.projects.ProjectReference;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
public final class PackerBackedAssetWorkspaceService implements AssetWorkspaceService {
private final FileSystemPackerWorkspaceService packerWorkspaceService;
public PackerBackedAssetWorkspaceService() {
this(new FileSystemPackerWorkspaceService());
}
public PackerBackedAssetWorkspaceService(FileSystemPackerWorkspaceService packerWorkspaceService) {
this.packerWorkspaceService = Objects.requireNonNull(packerWorkspaceService, "packerWorkspaceService");
}
@Override
public AssetWorkspaceSnapshot loadWorkspace(ProjectReference projectReference) {
final var result = packerWorkspaceService.listAssets(new ListAssetsRequest(project(projectReference)));
final List<AssetWorkspaceAssetSummary> assets = result.assets().stream()
.map(this::mapSummary)
.toList();
return new AssetWorkspaceSnapshot(assets);
}
@Override
public AssetWorkspaceAssetDetails loadAssetDetails(ProjectReference projectReference, AssetWorkspaceSelectionKey selectionKey) {
final String assetReference = switch (selectionKey) {
case AssetWorkspaceSelectionKey.ManagedAsset managedAsset -> Integer.toString(managedAsset.assetId());
case AssetWorkspaceSelectionKey.OrphanAsset orphanAsset -> relativeAssetRoot(projectReference, orphanAsset.assetRoot());
};
final var result = packerWorkspaceService.getAssetDetails(new GetAssetDetailsRequest(project(projectReference), assetReference));
return mapDetails(result.details());
}
private AssetWorkspaceAssetSummary mapSummary(PackerAssetSummary summary) {
final AssetWorkspaceAssetState state = toStudioState(summary);
final AssetWorkspaceSelectionKey selectionKey = state == AssetWorkspaceAssetState.MANAGED
? new AssetWorkspaceSelectionKey.ManagedAsset(summary.identity().assetId())
: new AssetWorkspaceSelectionKey.OrphanAsset(summary.identity().assetRoot());
return new AssetWorkspaceAssetSummary(
selectionKey,
summary.identity().assetName(),
state,
summary.identity().assetId(),
summary.assetFamily(),
summary.identity().assetRoot(),
summary.preloadEnabled(),
summary.hasDiagnostics());
}
private AssetWorkspaceAssetDetails mapDetails(PackerAssetDetails details) {
return new AssetWorkspaceAssetDetails(
mapSummary(details.summary()),
details.outputFormat(),
details.outputCodec(),
details.inputsByRole(),
details.diagnostics().stream().map(this::mapDiagnostic).toList());
}
private AssetWorkspaceDiagnostic mapDiagnostic(PackerDiagnostic diagnostic) {
return new AssetWorkspaceDiagnostic(
switch (diagnostic.severity()) {
case ERROR -> AssetWorkspaceDiagnosticSeverity.BLOCKER;
case WARNING -> AssetWorkspaceDiagnosticSeverity.WARNING;
case INFO -> AssetWorkspaceDiagnosticSeverity.HINT;
},
diagnostic.message());
}
private AssetWorkspaceAssetState toStudioState(PackerAssetSummary summary) {
if (summary.state() == PackerAssetState.MANAGED) {
return AssetWorkspaceAssetState.MANAGED;
}
return summary.identity().assetId() != null ? AssetWorkspaceAssetState.MANAGED : AssetWorkspaceAssetState.ORPHAN;
}
private PackerProjectContext project(ProjectReference projectReference) {
final ProjectReference reference = Objects.requireNonNull(projectReference, "projectReference");
return new PackerProjectContext(reference.name(), reference.rootPath());
}
private String relativeAssetRoot(ProjectReference projectReference, Path assetRoot) {
return projectReference.rootPath().resolve("assets").toAbsolutePath().normalize()
.relativize(assetRoot.toAbsolutePath().normalize())
.toString()
.replace('\\', '/');
}
}

View File

@ -0,0 +1,93 @@
package p.studio.workspaces.assets;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.studio.projects.ProjectReference;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
final class PackerBackedAssetWorkspaceServiceTest {
@TempDir
Path tempDir;
@Test
void loadsWorkspaceThroughPackerBackedSnapshot() throws Exception {
final Path projectRoot = tempDir.resolve("main");
final Path managedRoot = projectRoot.resolve("assets/ui/atlas");
final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds");
Files.createDirectories(managedRoot);
Files.createDirectories(orphanRoot);
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
Files.writeString(managedRoot.resolve("asset.json"), """
{
"schema_version": 1,
"name": "ui_atlas",
"type": "image_bank",
"inputs": { "sprites": ["sprites/confirm.png"] },
"output": { "format": "TILES/indexed_v1", "codec": "RAW" },
"preload": { "enabled": true }
}
""");
Files.writeString(orphanRoot.resolve("asset.json"), """
{
"schema_version": 1,
"name": "ui_sounds",
"type": "sound_bank",
"inputs": { "sources": ["confirm.wav"] },
"output": { "format": "SOUND/bank_v1", "codec": "RAW" },
"preload": { "enabled": false }
}
""");
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"
}
]
}
""");
final PackerBackedAssetWorkspaceService service = new PackerBackedAssetWorkspaceService();
final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot));
assertEquals(2, snapshot.assets().size());
assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.MANAGED));
assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.ORPHAN));
}
@Test
void mapsInvalidDetailsToStudioDiagnostics() throws Exception {
final Path projectRoot = tempDir.resolve("main");
final Path invalidRoot = projectRoot.resolve("assets/bad");
Files.createDirectories(invalidRoot);
Files.writeString(invalidRoot.resolve("asset.json"), """
{
"schema_version": 1,
"type": "image_bank"
}
""");
final PackerBackedAssetWorkspaceService service = new PackerBackedAssetWorkspaceService();
final AssetWorkspaceAssetDetails details = service.loadAssetDetails(
project("Main", projectRoot),
new AssetWorkspaceSelectionKey.OrphanAsset(invalidRoot));
assertEquals("unknown", details.outputFormat());
assertFalse(details.diagnostics().isEmpty());
assertEquals(AssetWorkspaceDiagnosticSeverity.BLOCKER, details.diagnostics().getFirst().severity());
}
private ProjectReference project(String name, Path root) {
return new ProjectReference(name, "1.0.0", "pbs", 1, root);
}
}