implements PLN-0041 shipper preparation foundation

This commit is contained in:
bQUARKz 2026-04-06 06:34:43 +01:00
parent 6649b11ec4
commit 70b9a183ba
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
9 changed files with 461 additions and 8 deletions

View File

@ -10,10 +10,12 @@ dependencies {
implementation(project(":prometeu-packer:prometeu-packer-api"))
implementation(project(":prometeu-compiler:prometeu-compiler-core"))
implementation(project(":prometeu-compiler:prometeu-build-pipeline"))
implementation(project(":prometeu-compiler:prometeu-frontend-api"))
implementation(project(":prometeu-compiler:prometeu-frontend-registry"))
implementation(libs.javafx.controls)
implementation(libs.javafx.fxml)
implementation(libs.richtextfx)
testImplementation(project(":prometeu-packer:prometeu-packer-v1"))
}
javafx {

View File

@ -0,0 +1,33 @@
package p.studio.shipper;
import java.time.Instant;
import java.util.Objects;
public record StudioShipperLogEntry(
StudioShipperLogSource source,
StudioShipperLogLevel level,
String message,
Instant timestamp) {
public StudioShipperLogEntry {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(level, "level");
message = Objects.requireNonNull(message, "message").trim();
timestamp = timestamp == null ? Instant.now() : timestamp;
if (message.isBlank()) {
throw new IllegalArgumentException("message must not be blank");
}
}
public static StudioShipperLogEntry info(
final StudioShipperLogSource source,
final String message) {
return new StudioShipperLogEntry(source, StudioShipperLogLevel.INFO, message, Instant.now());
}
public static StudioShipperLogEntry error(
final StudioShipperLogSource source,
final String message) {
return new StudioShipperLogEntry(source, StudioShipperLogLevel.ERROR, message, Instant.now());
}
}

View File

@ -0,0 +1,6 @@
package p.studio.shipper;
public enum StudioShipperLogLevel {
INFO,
ERROR
}

View File

@ -0,0 +1,8 @@
package p.studio.shipper;
public enum StudioShipperLogSource {
BUILD,
PACK_VALIDATION,
PACK,
MANIFEST
}

View File

@ -0,0 +1,6 @@
package p.studio.shipper;
public enum StudioShipperPreparationStatus {
SUCCESS,
FAILED
}

View File

@ -0,0 +1,28 @@
package p.studio.shipper;
import p.packer.messages.PackWorkspaceResult;
import p.packer.messages.ValidatePackWorkspaceResult;
import p.studio.compiler.models.BuildResult;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
public record StudioShipperPrepareResult(
StudioShipperPreparationStatus status,
List<StudioShipperLogEntry> logs,
BuildResult buildResult,
ValidatePackWorkspaceResult validationResult,
PackWorkspaceResult packResult,
Path manifestPath) {
public StudioShipperPrepareResult {
Objects.requireNonNull(status, "status");
logs = List.copyOf(Objects.requireNonNull(logs, "logs"));
manifestPath = manifestPath == null ? null : manifestPath.toAbsolutePath().normalize();
}
public boolean success() {
return status == StudioShipperPreparationStatus.SUCCESS;
}
}

View File

@ -0,0 +1,275 @@
package p.studio.shipper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import p.packer.PackerWorkspaceService;
import p.packer.dtos.PackerDiagnosticDTO;
import p.packer.dtos.PackerPackValidationAssetDTO;
import p.packer.messages.PackWorkspaceRequest;
import p.packer.messages.PackWorkspaceResult;
import p.packer.messages.PackerOperationStatus;
import p.packer.messages.ValidatePackWorkspaceRequest;
import p.packer.messages.ValidatePackWorkspaceResult;
import p.studio.Container;
import p.studio.compiler.messages.BuilderPipelineConfig;
import p.studio.compiler.models.BuildResult;
import p.studio.compiler.models.BuilderPipelineContext;
import p.studio.compiler.workspaces.BuilderPipelineService;
import p.studio.projects.ProjectReference;
import p.studio.utilities.logs.LogAggregator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
public final class StudioShipperService {
private static final String BUILD_DIR = "build";
private static final String MANIFEST_FILE = "manifest.json";
private static final String PROJECT_MANIFEST_FILE = "prometeu.json";
private static final String ASSET_TABLE_FILE = "asset_table.json";
private static final String PRELOAD_FILE = "preload.json";
private static final ObjectMapper MAPPER = new ObjectMapper();
private final PackerWorkspaceService packerWorkspaceService;
public StudioShipperService() {
this(Container.packer().workspaceService());
}
public StudioShipperService(final PackerWorkspaceService packerWorkspaceService) {
this.packerWorkspaceService = Objects.requireNonNull(packerWorkspaceService, "packerWorkspaceService");
}
public StudioShipperPrepareResult prepare(final ProjectReference projectReference) {
return prepare(projectReference, ignored -> { });
}
public StudioShipperPrepareResult prepare(
final ProjectReference projectReference,
final Consumer<StudioShipperLogEntry> logSink) {
final ProjectReference safeProjectReference = Objects.requireNonNull(projectReference, "projectReference");
final Consumer<StudioShipperLogEntry> safeLogSink = Objects.requireNonNull(logSink, "logSink");
final ArrayList<StudioShipperLogEntry> logs = new ArrayList<>();
try {
final BuildResult buildResult = build(safeProjectReference, logs, safeLogSink);
final ValidatePackWorkspaceResult validationResult = validatePack(safeProjectReference, logs, safeLogSink);
if (!validationResult.canPack()) {
emit(logs, safeLogSink, StudioShipperLogEntry.error(
StudioShipperLogSource.PACK_VALIDATION,
validationResult.summary()));
logBlockedAssets(validationResult.assets(), logs, safeLogSink);
return new StudioShipperPrepareResult(
StudioShipperPreparationStatus.FAILED,
logs,
buildResult,
validationResult,
null,
null);
}
final PackWorkspaceResult packResult = pack(safeProjectReference, logs, safeLogSink);
if (packResult.status() != PackerOperationStatus.SUCCESS) {
emit(logs, safeLogSink, logFor(packResult.status(), StudioShipperLogSource.PACK, packResult.summary()));
return new StudioShipperPrepareResult(
StudioShipperPreparationStatus.FAILED,
logs,
buildResult,
validationResult,
packResult,
null);
}
final Path manifestPath = writeManifest(safeProjectReference, buildResult, logs, safeLogSink);
return new StudioShipperPrepareResult(
StudioShipperPreparationStatus.SUCCESS,
logs,
buildResult,
validationResult,
packResult,
manifestPath);
} catch (RuntimeException runtimeException) {
emit(logs, safeLogSink, StudioShipperLogEntry.error(
StudioShipperLogSource.BUILD,
runtimeException.getMessage() == null ? runtimeException.getClass().getSimpleName() : runtimeException.getMessage()));
return new StudioShipperPrepareResult(
StudioShipperPreparationStatus.FAILED,
logs,
null,
null,
null,
null);
}
}
private BuildResult build(
final ProjectReference projectReference,
final List<StudioShipperLogEntry> logs,
final Consumer<StudioShipperLogEntry> logSink) {
final var config = new BuilderPipelineConfig(false, projectReference.rootPath().toString());
final var ctx = BuilderPipelineContext.fromConfig(config);
final var logAggregator = LogAggregator.with(message -> emit(logs, logSink, StudioShipperLogEntry.info(
StudioShipperLogSource.BUILD,
normalizeMessage(message))));
final BuildResult result = BuilderPipelineService.INSTANCE.build(ctx, logAggregator);
if (result.bytecodeArtifactPath() != null) {
emit(logs, logSink, StudioShipperLogEntry.info(
StudioShipperLogSource.BUILD,
"Prepared program bytecode at " + result.bytecodeArtifactPath().toAbsolutePath().normalize()));
}
return result;
}
private ValidatePackWorkspaceResult validatePack(
final ProjectReference projectReference,
final List<StudioShipperLogEntry> logs,
final Consumer<StudioShipperLogEntry> logSink) {
final ValidatePackWorkspaceResult result = packerWorkspaceService
.validatePackWorkspace(new ValidatePackWorkspaceRequest(projectReference.toPackerProjectContext()));
// keep the validation source explicit even when the downstream packer reports partial status
emit(logs, logSink, logFor(result.status(), StudioShipperLogSource.PACK_VALIDATION, result.summary()));
if (!result.canPack()) {
logBlockedAssets(result.assets(), logs, logSink);
}
return result;
}
private PackWorkspaceResult pack(
final ProjectReference projectReference,
final List<StudioShipperLogEntry> logs,
final Consumer<StudioShipperLogEntry> logSink) {
final PackWorkspaceResult result = packerWorkspaceService
.packWorkspace(new PackWorkspaceRequest(projectReference.toPackerProjectContext()));
emit(logs, logSink, logFor(result.status(), StudioShipperLogSource.PACK, result.summary()));
result.result().emittedArtifacts().forEach(artifact -> emit(logs, logSink, StudioShipperLogEntry.info(
StudioShipperLogSource.PACK,
"Emitted " + artifact.label() + " at " + artifact.path())));
return result;
}
private Path writeManifest(
final ProjectReference projectReference,
final BuildResult buildResult,
final List<StudioShipperLogEntry> logs,
final Consumer<StudioShipperLogEntry> logSink) {
final Path buildRoot = projectReference.rootPath().resolve(BUILD_DIR).toAbsolutePath().normalize();
final Path manifestPath = buildRoot.resolve(MANIFEST_FILE);
try {
Files.createDirectories(buildRoot);
final ObjectNode manifest = createManifest(projectReference, buildResult, buildRoot);
MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
emit(logs, logSink, StudioShipperLogEntry.info(
StudioShipperLogSource.MANIFEST,
"Generated manifest at " + manifestPath));
return manifestPath;
} catch (IOException ioException) {
emit(logs, logSink, StudioShipperLogEntry.error(
StudioShipperLogSource.MANIFEST,
"Failed to write manifest: " + manifestPath));
throw new RuntimeException("failed to write build manifest: " + manifestPath, ioException);
}
}
private ObjectNode createManifest(
final ProjectReference projectReference,
final BuildResult buildResult,
final Path buildRoot) throws IOException {
final ObjectNode manifest = MAPPER.createObjectNode();
final JsonNode projectManifest = MAPPER.readTree(projectReference.rootPath().resolve(PROJECT_MANIFEST_FILE).toFile());
manifest.put("magic", "PMTU");
manifest.put("cartridge_version", 1);
manifest.put("app_id", stableAppId(projectReference));
manifest.put("title", projectReference.name());
manifest.put("app_version", projectReference.version());
manifest.put("app_mode", "game");
manifest.set("capabilities", capabilitiesNode(buildResult));
manifest.set("asset_table", readArrayOrEmpty(buildRoot.resolve(ASSET_TABLE_FILE)));
manifest.set("preload", readArrayOrEmpty(buildRoot.resolve(PRELOAD_FILE)));
if (projectManifest != null) {
putIfTextual(manifest, "project_name", projectManifest.get("name"));
putIfTextual(manifest, "language", projectManifest.get("language"));
}
return manifest;
}
private ArrayNode capabilitiesNode(final BuildResult buildResult) {
final ArrayNode capabilities = MAPPER.createArrayNode();
final var irBackend = buildResult.compileResult().analysisSnapshot().irBackend();
final LinkedHashSet<String> distinct = new LinkedHashSet<>(irBackend.getReservedMetadata().requiredCapabilities().asList());
distinct.forEach(capabilities::add);
return capabilities;
}
private JsonNode readArrayOrEmpty(final Path path) throws IOException {
if (!Files.isRegularFile(path)) {
return MAPPER.createArrayNode();
}
final JsonNode loaded = MAPPER.readTree(path.toFile());
return loaded != null && loaded.isArray() ? loaded : MAPPER.createArrayNode();
}
private int stableAppId(final ProjectReference projectReference) {
final int hash = Objects.hash(
projectReference.name(),
projectReference.version(),
projectReference.languageId(),
projectReference.stdlibVersion());
return hash == Integer.MIN_VALUE ? 1 : Math.max(1, Math.abs(hash));
}
private void putIfTextual(
final ObjectNode node,
final String fieldName,
final JsonNode value) {
if (value != null && value.isTextual() && !value.asText().isBlank()) {
node.put(fieldName, value.asText());
}
}
private void logBlockedAssets(
final List<PackerPackValidationAssetDTO> assets,
final List<StudioShipperLogEntry> logs,
final Consumer<StudioShipperLogEntry> logSink) {
for (final PackerPackValidationAssetDTO asset : assets) {
emit(logs, logSink, StudioShipperLogEntry.error(
StudioShipperLogSource.PACK_VALIDATION,
"Blocked asset #" + asset.assetId() + " (" + asset.assetName() + ")"));
for (final PackerDiagnosticDTO diagnostic : asset.diagnostics()) {
emit(logs, logSink, StudioShipperLogEntry.error(
StudioShipperLogSource.PACK_VALIDATION,
diagnostic.message()));
}
}
}
private StudioShipperLogEntry logFor(
final PackerOperationStatus status,
final StudioShipperLogSource source,
final String message) {
return status == PackerOperationStatus.FAILED || status == PackerOperationStatus.PARTIAL
? StudioShipperLogEntry.error(source, message)
: StudioShipperLogEntry.info(source, message);
}
private void emit(
final List<StudioShipperLogEntry> logs,
final Consumer<StudioShipperLogEntry> logSink,
final StudioShipperLogEntry entry) {
logs.add(entry);
logSink.accept(entry);
}
private String normalizeMessage(final String message) {
if (message == null) {
return "(empty build log entry)";
}
final String trimmed = message.strip();
return trimmed.isEmpty() ? "(empty build log entry)" : trimmed;
}
}

View File

@ -6,12 +6,9 @@ import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import p.studio.Container;
import p.studio.compiler.messages.BuilderPipelineConfig;
import p.studio.compiler.models.BuilderPipelineContext;
import p.studio.compiler.workspaces.BuilderPipelineService;
import p.studio.projects.ProjectReference;
import p.studio.shipper.StudioShipperService;
import p.studio.utilities.i18n.I18n;
import p.studio.utilities.logs.LogAggregator;
import p.studio.workspaces.Workspace;
import p.studio.workspaces.WorkspaceId;
@ -20,6 +17,7 @@ public class ShipperWorkspace extends Workspace {
private final TextArea logs = new TextArea();
private final Button buildButton = new Button();
private final Button clearButton = new Button();
private final StudioShipperService shipperService = new StudioShipperService();
public ShipperWorkspace(ProjectReference projectReference) {
super(projectReference);
@ -66,10 +64,8 @@ public class ShipperWorkspace extends Workspace {
buildButton.getStyleClass().addAll("studio-button", "studio-button-primary");
buildButton.setOnAction(e -> {
logs.clear();
final var logAggregator = LogAggregator.with(logs::appendText);
final var config = new BuilderPipelineConfig(false, projectReference.rootPath().toString());
final var ctx = BuilderPipelineContext.fromConfig(config);
BuilderPipelineService.INSTANCE.build(ctx, logAggregator);
shipperService.prepare(projectReference, entry -> logs.appendText(
"[%s] %s%s".formatted(entry.source().name(), entry.message(), System.lineSeparator())));
});
clearButton.textProperty().bind(Container.i18n().bind(I18n.WORKSPACE_SHIPPER_BUTTON_CLEAR));

View File

@ -0,0 +1,99 @@
package p.studio.shipper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.Packer;
import p.studio.projects.ProjectCatalogService;
import p.studio.projects.ProjectReference;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import static org.junit.jupiter.api.Assertions.*;
final class StudioShipperServiceTest {
@TempDir
Path tempDir;
@Test
void prepareBuildsPackAndManifestForMainFixture() throws Exception {
final Path projectRoot = copyFixture(fixturePath("test-projects/main"), tempDir.resolve("main-project"));
final ProjectReference projectReference = new ProjectCatalogService(tempDir).openProject(projectRoot);
final ArrayList<StudioShipperLogEntry> streamedLogs = new ArrayList<>();
final StudioShipperPrepareResult result;
try (final Packer packer = Packer.bootstrap(new ObjectMapper(), ignored -> { })) {
final StudioShipperService service = new StudioShipperService(packer.workspaceService());
result = service.prepare(projectReference, streamedLogs::add);
}
assertTrue(result.success());
assertEquals(StudioShipperPreparationStatus.SUCCESS, result.status());
assertNotNull(result.buildResult());
assertNotNull(result.validationResult());
assertNotNull(result.packResult());
assertNotNull(result.manifestPath());
assertEquals(result.logs(), streamedLogs);
assertTrue(Files.isRegularFile(projectRoot.resolve("build").resolve("program.pbx")));
assertTrue(Files.isRegularFile(projectRoot.resolve("build").resolve("assets.pa")));
assertTrue(Files.isRegularFile(projectRoot.resolve("build").resolve("manifest.json")));
assertTrue(result.logs().stream().anyMatch(entry -> entry.source() == StudioShipperLogSource.BUILD));
assertTrue(result.logs().stream().anyMatch(entry -> entry.source() == StudioShipperLogSource.PACK_VALIDATION));
assertTrue(result.logs().stream().anyMatch(entry -> entry.source() == StudioShipperLogSource.PACK));
assertTrue(result.logs().stream().anyMatch(entry -> entry.source() == StudioShipperLogSource.MANIFEST));
final String manifestJson = Files.readString(result.manifestPath());
assertTrue(manifestJson.contains("\"magic\""));
assertTrue(manifestJson.contains("\"capabilities\""));
assertTrue(manifestJson.contains("\"asset_table\""));
assertTrue(manifestJson.contains("\"preload\""));
}
@Test
void prepareManifestPreservesCompilerCapabilities() throws Exception {
final Path projectRoot = copyFixture(fixturePath("test-projects/main"), tempDir.resolve("main-capabilities"));
final ProjectReference projectReference = new ProjectCatalogService(tempDir).openProject(projectRoot);
final StudioShipperPrepareResult result;
try (final Packer packer = Packer.bootstrap(new ObjectMapper(), ignored -> { })) {
final StudioShipperService service = new StudioShipperService(packer.workspaceService());
result = service.prepare(projectReference);
}
assertTrue(result.success());
final String manifestJson = Files.readString(result.manifestPath());
assertTrue(manifestJson.contains("\"log\""));
assertTrue(manifestJson.contains("\"gfx\""));
assertTrue(manifestJson.contains("\"asset\""));
}
private Path copyFixture(final Path sourceRoot, final Path targetRoot) throws IOException {
Files.walk(sourceRoot).forEach(source -> {
try {
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);
}
} catch (IOException ioException) {
throw new RuntimeException(ioException);
}
});
return targetRoot;
}
private Path fixturePath(final String relativePath) {
final Path direct = Path.of(relativePath).toAbsolutePath().normalize();
if (Files.exists(direct)) {
return direct;
}
final Path parent = Path.of("..").resolve(relativePath).toAbsolutePath().normalize();
if (Files.exists(parent)) {
return parent;
}
throw new IllegalArgumentException("fixture not found: " + relativePath);
}
}