implements PLN-0042 shared execution session contracts
This commit is contained in:
parent
70b9a183ba
commit
56be8fa69e
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package p.studio.execution;
|
||||
|
||||
public enum StudioExecutionState {
|
||||
IDLE,
|
||||
PREPARING,
|
||||
PREPARE_FAILED,
|
||||
CONNECTING,
|
||||
RUNNING,
|
||||
RUNTIME_FAILED,
|
||||
STOPPED
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user