diff --git a/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionLogEntry.java b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionLogEntry.java new file mode 100644 index 00000000..ad6e9b4d --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionLogEntry.java @@ -0,0 +1,25 @@ +package p.studio.execution; + +import java.time.Instant; +import java.util.Objects; + +public record StudioExecutionLogEntry( + long sequence, + Instant timestamp, + StudioExecutionLogSource source, + StudioExecutionLogSeverity severity, + String message) { + + public StudioExecutionLogEntry { + if (sequence <= 0L) { + throw new IllegalArgumentException("sequence must be positive"); + } + timestamp = timestamp == null ? Instant.now() : timestamp; + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(severity, "severity"); + message = Objects.requireNonNull(message, "message").trim(); + if (message.isBlank()) { + throw new IllegalArgumentException("message must not be blank"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionLogSeverity.java b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionLogSeverity.java new file mode 100644 index 00000000..93629a84 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionLogSeverity.java @@ -0,0 +1,15 @@ +package p.studio.execution; + +import p.studio.shipper.StudioShipperLogLevel; + +public enum StudioExecutionLogSeverity { + INFO, + ERROR; + + public static StudioExecutionLogSeverity fromShipper(final StudioShipperLogLevel level) { + return switch (level) { + case INFO -> INFO; + case ERROR -> ERROR; + }; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionLogSource.java b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionLogSource.java new file mode 100644 index 00000000..7f10d8c4 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionLogSource.java @@ -0,0 +1,20 @@ +package p.studio.execution; + +import p.studio.shipper.StudioShipperLogSource; + +public enum StudioExecutionLogSource { + BUILD, + PACK_VALIDATION, + PACK, + MANIFEST, + RUNTIME; + + public static StudioExecutionLogSource fromShipper(final StudioShipperLogSource source) { + return switch (source) { + case BUILD -> BUILD; + case PACK_VALIDATION -> PACK_VALIDATION; + case PACK -> PACK; + case MANIFEST -> MANIFEST; + }; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionSessionService.java b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionSessionService.java new file mode 100644 index 00000000..b69ad2c6 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionSessionService.java @@ -0,0 +1,155 @@ +package p.studio.execution; + +import p.studio.shipper.StudioShipperLogEntry; +import p.studio.utilities.events.EventSubscription; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +public final class StudioExecutionSessionService { + private final Object monitor = new Object(); + private final ArrayList logs = new ArrayList<>(); + private final CopyOnWriteArrayList> listeners = new CopyOnWriteArrayList<>(); + private StudioExecutionState state = StudioExecutionState.IDLE; + private long nextSequence = 1L; + + public StudioExecutionSnapshot snapshot() { + synchronized (monitor) { + return snapshotLocked(); + } + } + + public EventSubscription subscribe(final Consumer listener) { + final Consumer safeListener = Objects.requireNonNull(listener, "listener"); + listeners.add(safeListener); + safeListener.accept(snapshot()); + return () -> listeners.remove(safeListener); + } + + public void beginPreparationCycle() { + publish(transitionLocked(StudioExecutionState.PREPARING, true)); + } + + public void transitionToPrepareFailed() { + publish(transitionLocked(StudioExecutionState.PREPARE_FAILED, false)); + } + + public void transitionToConnecting() { + publish(transitionLocked(StudioExecutionState.CONNECTING, false)); + } + + public void transitionToRunning() { + publish(transitionLocked(StudioExecutionState.RUNNING, false)); + } + + public void transitionToRuntimeFailed() { + publish(transitionLocked(StudioExecutionState.RUNTIME_FAILED, false)); + } + + public void transitionToStopped() { + publish(transitionLocked(StudioExecutionState.STOPPED, false)); + } + + public void resetToIdle() { + publish(transitionLocked(StudioExecutionState.IDLE, false)); + } + + public void appendShipperLog(final StudioShipperLogEntry entry) { + final StudioShipperLogEntry safeEntry = Objects.requireNonNull(entry, "entry"); + appendLog( + StudioExecutionLogSource.fromShipper(safeEntry.source()), + StudioExecutionLogSeverity.fromShipper(safeEntry.level()), + safeEntry.message(), + safeEntry.timestamp()); + } + + public void appendRuntimeLog( + final StudioExecutionLogSeverity severity, + final String message) { + appendLog(StudioExecutionLogSource.RUNTIME, severity, message, Instant.now()); + } + + public void appendRuntimeLog( + final StudioExecutionLogSeverity severity, + final String message, + final Instant timestamp) { + appendLog(StudioExecutionLogSource.RUNTIME, severity, message, timestamp); + } + + public void appendLog( + final StudioExecutionLogSource source, + final StudioExecutionLogSeverity severity, + final String message, + final Instant timestamp) { + final StudioExecutionSnapshot snapshot; + synchronized (monitor) { + final StudioExecutionLogEntry entry = new StudioExecutionLogEntry( + nextSequence++, + timestamp, + Objects.requireNonNull(source, "source"), + Objects.requireNonNull(severity, "severity"), + message); + logs.add(entry); + snapshot = snapshotLocked(); + } + publish(snapshot); + } + + private StudioExecutionSnapshot transitionLocked( + final StudioExecutionState targetState, + final boolean clearLogs) { + final StudioExecutionSnapshot snapshot; + synchronized (monitor) { + final StudioExecutionState next = Objects.requireNonNull(targetState, "targetState"); + ensureTransitionAllowed(state, next); + state = next; + if (clearLogs) { + logs.clear(); + nextSequence = 1L; + } + snapshot = snapshotLocked(); + } + return snapshot; + } + + private StudioExecutionSnapshot snapshotLocked() { + return new StudioExecutionSnapshot(state, List.copyOf(logs)); + } + + private void ensureTransitionAllowed( + final StudioExecutionState currentState, + final StudioExecutionState nextState) { + if (currentState == nextState) { + return; + } + final boolean allowed = switch (currentState) { + case IDLE -> nextState == StudioExecutionState.PREPARING; + case PREPARING -> nextState == StudioExecutionState.PREPARE_FAILED + || nextState == StudioExecutionState.CONNECTING; + case PREPARE_FAILED -> nextState == StudioExecutionState.IDLE + || nextState == StudioExecutionState.PREPARING; + case CONNECTING -> nextState == StudioExecutionState.RUNNING + || nextState == StudioExecutionState.RUNTIME_FAILED + || nextState == StudioExecutionState.STOPPED; + case RUNNING -> nextState == StudioExecutionState.STOPPED + || nextState == StudioExecutionState.RUNTIME_FAILED; + case RUNTIME_FAILED -> nextState == StudioExecutionState.IDLE + || nextState == StudioExecutionState.PREPARING; + case STOPPED -> nextState == StudioExecutionState.IDLE + || nextState == StudioExecutionState.PREPARING; + }; + if (!allowed) { + throw new IllegalStateException("illegal execution transition: " + currentState + " -> " + nextState); + } + } + + private void publish(final StudioExecutionSnapshot snapshot) { + for (final Consumer listener : listeners) { + listener.accept(snapshot); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionSnapshot.java b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionSnapshot.java new file mode 100644 index 00000000..5c25c990 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionSnapshot.java @@ -0,0 +1,14 @@ +package p.studio.execution; + +import java.util.List; +import java.util.Objects; + +public record StudioExecutionSnapshot( + StudioExecutionState state, + List logs) { + + public StudioExecutionSnapshot { + Objects.requireNonNull(state, "state"); + logs = List.copyOf(Objects.requireNonNull(logs, "logs")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionState.java b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionState.java new file mode 100644 index 00000000..49e3e6d3 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/execution/StudioExecutionState.java @@ -0,0 +1,11 @@ +package p.studio.execution; + +public enum StudioExecutionState { + IDLE, + PREPARING, + PREPARE_FAILED, + CONNECTING, + RUNNING, + RUNTIME_FAILED, + STOPPED +} diff --git a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java index b6c9fe29..68401342 100644 --- a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java +++ b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java @@ -1,5 +1,6 @@ package p.studio.projectsessions; +import p.studio.execution.StudioExecutionSessionService; import p.studio.lsp.LspService; import p.studio.projectstate.ProjectLocalStudioSetup; import p.studio.projectstate.ProjectLocalStudioState; @@ -15,6 +16,7 @@ public final class StudioProjectSession implements AutoCloseable { private final VfsProjectDocument vfsProjectDocument; private final ProjectLocalStudioStateService projectLocalStudioStateService; private final ProjectLocalStudioSetup projectLocalStudioSetup; + private final StudioExecutionSessionService executionSessionService; private ProjectLocalStudioState projectLocalStudioState; private boolean closed; @@ -28,6 +30,7 @@ public final class StudioProjectSession implements AutoCloseable { vfsProjectDocument, new ProjectLocalStudioStateService(), ProjectLocalStudioSetup.defaults(), + new StudioExecutionSessionService(), ProjectLocalStudioState.defaults()); } @@ -37,12 +40,14 @@ public final class StudioProjectSession implements AutoCloseable { final VfsProjectDocument vfsProjectDocument, final ProjectLocalStudioStateService projectLocalStudioStateService, final ProjectLocalStudioSetup projectLocalStudioSetup, + final StudioExecutionSessionService executionSessionService, final ProjectLocalStudioState projectLocalStudioState) { this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); this.prometeuLspService = Objects.requireNonNull(prometeuLspService, "prometeuLspService"); this.vfsProjectDocument = Objects.requireNonNull(vfsProjectDocument, "vfsProjectDocument"); this.projectLocalStudioStateService = Objects.requireNonNull(projectLocalStudioStateService, "projectLocalStudioStateService"); this.projectLocalStudioSetup = Objects.requireNonNull(projectLocalStudioSetup, "projectLocalStudioSetup"); + this.executionSessionService = Objects.requireNonNull(executionSessionService, "executionSessionService"); this.projectLocalStudioState = Objects.requireNonNull(projectLocalStudioState, "projectLocalStudioState"); } @@ -66,6 +71,10 @@ public final class StudioProjectSession implements AutoCloseable { return projectLocalStudioSetup; } + public StudioExecutionSessionService executionSession() { + return executionSessionService; + } + public void replaceProjectLocalStudioState(final ProjectLocalStudioState nextProjectLocalStudioState) { this.projectLocalStudioState = Objects.requireNonNull(nextProjectLocalStudioState, "nextProjectLocalStudioState"); } diff --git a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java index 63e4af74..cedb35c5 100644 --- a/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java +++ b/prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java @@ -1,5 +1,6 @@ package p.studio.projectsessions; +import p.studio.execution.StudioExecutionSessionService; import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.LspServiceFactory; import p.studio.projectstate.ProjectLocalStudioSetupService; @@ -46,6 +47,7 @@ public final class StudioProjectSessionFactory { vfsProjectDocument, projectLocalStudioStateService, projectLocalStudioSetupService.load(target), + new StudioExecutionSessionService(), projectLocalStudioStateService.load(target)); } diff --git a/prometeu-studio/src/test/java/p/studio/execution/StudioExecutionSessionServiceTest.java b/prometeu-studio/src/test/java/p/studio/execution/StudioExecutionSessionServiceTest.java new file mode 100644 index 00000000..5fad4a0e --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/execution/StudioExecutionSessionServiceTest.java @@ -0,0 +1,89 @@ +package p.studio.execution; + +import org.junit.jupiter.api.Test; +import p.studio.shipper.StudioShipperLogEntry; +import p.studio.shipper.StudioShipperLogSource; + +import java.time.Instant; +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +final class StudioExecutionSessionServiceTest { + @Test + void enforcesTheMinimumExecutionStateMachine() { + final StudioExecutionSessionService service = new StudioExecutionSessionService(); + + assertEquals(StudioExecutionState.IDLE, service.snapshot().state()); + assertThrows(IllegalStateException.class, service::transitionToRunning); + + service.beginPreparationCycle(); + assertEquals(StudioExecutionState.PREPARING, service.snapshot().state()); + assertThrows(IllegalStateException.class, service::transitionToStopped); + + service.transitionToPrepareFailed(); + assertEquals(StudioExecutionState.PREPARE_FAILED, service.snapshot().state()); + + service.beginPreparationCycle(); + service.transitionToConnecting(); + service.transitionToRunning(); + service.transitionToStopped(); + + assertEquals(StudioExecutionState.STOPPED, service.snapshot().state()); + assertThrows(IllegalStateException.class, service::transitionToConnecting); + } + + @Test + void preservesSourceIdentityAndSequenceAcrossMergedLogs() { + final StudioExecutionSessionService service = new StudioExecutionSessionService(); + final Instant now = Instant.parse("2026-04-06T10:15:30Z"); + + service.beginPreparationCycle(); + service.appendShipperLog(new StudioShipperLogEntry( + StudioShipperLogSource.BUILD, + p.studio.shipper.StudioShipperLogLevel.INFO, + "Compiler started", + now)); + service.appendShipperLog(new StudioShipperLogEntry( + StudioShipperLogSource.PACK_VALIDATION, + p.studio.shipper.StudioShipperLogLevel.ERROR, + "Pack validation failed", + now.plusSeconds(1))); + service.appendShipperLog(new StudioShipperLogEntry( + StudioShipperLogSource.PACK, + p.studio.shipper.StudioShipperLogLevel.INFO, + "Pack emitted", + now.plusSeconds(2))); + service.appendRuntimeLog( + StudioExecutionLogSeverity.INFO, + "Runtime connected", + now.plusSeconds(3)); + + final var logs = service.snapshot().logs(); + assertEquals(4, logs.size()); + assertEquals(StudioExecutionLogSource.BUILD, logs.get(0).source()); + assertEquals(StudioExecutionLogSource.PACK_VALIDATION, logs.get(1).source()); + assertEquals(StudioExecutionLogSource.PACK, logs.get(2).source()); + assertEquals(StudioExecutionLogSource.RUNTIME, logs.get(3).source()); + assertEquals(1L, logs.get(0).sequence()); + assertEquals(4L, logs.get(3).sequence()); + assertEquals(now.plusSeconds(3), logs.get(3).timestamp()); + } + + @Test + void subscribersReceiveSnapshotsOutsideWorkspaceLifecycle() { + final StudioExecutionSessionService service = new StudioExecutionSessionService(); + final ArrayList snapshots = new ArrayList<>(); + + final var subscription = service.subscribe(snapshots::add); + service.beginPreparationCycle(); + service.appendShipperLog(StudioShipperLogEntry.info(StudioShipperLogSource.BUILD, "Build ok")); + service.transitionToPrepareFailed(); + subscription.unsubscribe(); + + assertFalse(snapshots.isEmpty()); + assertEquals(StudioExecutionState.IDLE, snapshots.getFirst().state()); + assertEquals(StudioExecutionState.PREPARE_FAILED, snapshots.getLast().state()); + assertTrue(snapshots.getLast().logs().stream().anyMatch(entry -> entry.source() == StudioExecutionLogSource.BUILD)); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java index 9f0d51c3..42419295 100644 --- a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java +++ b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java @@ -58,6 +58,7 @@ final class StudioProjectSessionFactoryTest { assertSame(vfsFactory.vfs, lspFactory.capturedVfs); assertSame(stateService.loadedState, session.projectLocalStudioState()); assertSame(setupService.loadedSetup, session.projectLocalStudioSetup()); + assertEquals("IDLE", session.executionSession().snapshot().state().name()); assertSame(projectReference, stateService.loadedProjectReference); assertSame(projectReference, setupService.loadedProjectReference); } diff --git a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java index 15876b23..5b731bc8 100644 --- a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java +++ b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java @@ -1,6 +1,7 @@ package p.studio.projectsessions; import org.junit.jupiter.api.Test; +import p.studio.execution.StudioExecutionSessionService; import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.LspService; import p.studio.projectstate.ProjectLocalStudioSetup; @@ -35,6 +36,7 @@ final class StudioProjectSessionTest { vfs, stateService, ProjectLocalStudioSetup.defaults(), + new StudioExecutionSessionService(), ProjectLocalStudioState.defaults()); session.close(); @@ -57,6 +59,7 @@ final class StudioProjectSessionTest { vfs, stateService, ProjectLocalStudioSetup.defaults(), + new StudioExecutionSessionService(), ProjectLocalStudioState.defaults()); final ProjectLocalStudioState nextState = ProjectLocalStudioState.defaults() .withOpenShellState(new ProjectLocalStudioState.OpenShellState("EDITOR"));