implements PLN-0042 shared execution session contracts

This commit is contained in:
bQUARKz 2026-04-06 06:37:41 +01:00
parent 70b9a183ba
commit 56be8fa69e
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
11 changed files with 344 additions and 0 deletions

View File

@ -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");
}
}
}

View File

@ -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;
};
}
}

View File

@ -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;
};
}
}

View File

@ -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<StudioExecutionLogEntry> logs = new ArrayList<>();
private final CopyOnWriteArrayList<Consumer<StudioExecutionSnapshot>> listeners = new CopyOnWriteArrayList<>();
private StudioExecutionState state = StudioExecutionState.IDLE;
private long nextSequence = 1L;
public StudioExecutionSnapshot snapshot() {
synchronized (monitor) {
return snapshotLocked();
}
}
public EventSubscription subscribe(final Consumer<StudioExecutionSnapshot> listener) {
final Consumer<StudioExecutionSnapshot> 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<StudioExecutionSnapshot> listener : listeners) {
listener.accept(snapshot);
}
}
}

View File

@ -0,0 +1,14 @@
package p.studio.execution;
import java.util.List;
import java.util.Objects;
public record StudioExecutionSnapshot(
StudioExecutionState state,
List<StudioExecutionLogEntry> logs) {
public StudioExecutionSnapshot {
Objects.requireNonNull(state, "state");
logs = List.copyOf(Objects.requireNonNull(logs, "logs"));
}
}

View File

@ -0,0 +1,11 @@
package p.studio.execution;
public enum StudioExecutionState {
IDLE,
PREPARING,
PREPARE_FAILED,
CONNECTING,
RUNNING,
RUNTIME_FAILED,
STOPPED
}

View File

@ -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");
}

View File

@ -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));
}

View File

@ -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<StudioExecutionSnapshot> 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));
}
}

View File

@ -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);
}

View File

@ -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"));