8 Commits

Author SHA1 Message Date
f59cad115a Merge pull request 'S12_async-feedback' (#14) from S12_async-feedback into main
All checks were successful
Build, test and publish / verify (push) Successful in 4m37s
Reviewed-on: #14
2026-11-04 08:35:22 +01:00
a3cff48a92 S12 - turned schedule status into enum and some other review comments
All checks were successful
Build, test and publish / verify (push) Successful in 7m6s
2026-11-04 08:05:39 +01:00
c241392132 S12 - Async feedback for scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s
2026-11-03 23:18:44 +01:00
f1785ed174 S12 refinement result
All checks were successful
Build, test and publish / verify (push) Successful in 4m57s
2026-11-03 16:22:23 +01:00
b92fac1d1a Merge pull request 'S6: record production deployment and recovery evidence' (#13) from docs/S6-production-validation into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m9s
Reviewed-on: #13
2026-11-03 16:13:52 +01:00
95430691e4 updated spec status
All checks were successful
Build, test and publish / verify (push) Successful in 6m5s
2026-11-03 16:13:38 +01:00
95aa8c6302 testfile
Some checks failed
Build, test and publish / verify (push) Has been cancelled
2026-11-03 16:12:30 +01:00
3369b9a81c Record S6 production and NAS recovery acceptance [skip ci] 2026-11-03 15:34:31 +01:00
42 changed files with 3818 additions and 89 deletions

View File

@@ -83,7 +83,8 @@ the solver live in `model.scheduling`. Each group requirement combines its group
ID and appearance count in one record. ID and appearance count in one record.
`service.port.SchedulingEngine` is the application-owned interface for solving a `service.port.SchedulingEngine` is the application-owned interface for solving a
scheduling problem. `SchedulingService` calls this interface; dependency injection scheduling problem. `SchedulingRuns` calls this interface for background runs;
`SchedulingService` retains the synchronous service entry point. Dependency injection
supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain
types, so neither the caller nor the contract depends on OR-Tools or the concrete types, so neither the caller nor the contract depends on OR-Tools or the concrete
school-management service. Architecture tests enforce the port's location and school-management service. Architecture tests enforce the port's location and
@@ -160,11 +161,22 @@ dayparts; preschool groups once; showcases every daypart. See
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints, [show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
coach exceptions, ordering and optimization rules. coach exceptions, ordering and optimization rules.
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered `POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` returns `202` with a
draft using OR-Tools with a total thirty-minute time limit. A feasible result is run identifier and starts an ordered draft calculation in the background using
accepted without claiming optimality. Concurrent runs and stale results return OR-Tools with a total thirty-minute time limit. Duplicate starts for the same
`409`; failures retain existing assignments. Development and production proxies show return `409`; different shows may run concurrently. A feasible result is
allow 31 minutes. accepted without claiming optimality. Stale results and failures retain existing
assignments and appear as distinct terminal run states.
`GET .../shows/{showId}/schedule` returns the latest run (`204` if none exists).
`GET /api/scheduling/events` streams SSE JSON objects containing a `runs` snapshot
on connection and changes, or `runs: null` as a fifteen-second heartbeat. Clients
recover current state on reconnection rather than requiring event replay. The
production proxy disables buffering. The UI shows phase, elapsed time, solution
discovery and final objective scores, and reports completion in an application-wide
snackbar. Reloading recovers the latest state without replaying old notifications.
Run state currently shares the application's in-memory lifetime; durable restart
recovery requires the PostgreSQL integration specified in S16.
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an `PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved; ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingFailureKind {
INFEASIBLE,
NO_SOLUTION,
SOLVER_FAILED
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingPhase {
PREPARING,
ATTENDANCE,
SPREAD,
VARIETY
}

View File

@@ -0,0 +1,4 @@
package nl.agilecc.agiledancers.model.scheduling;
/** Progress is observational; intermediate assignments are never published or applied. */
public record SchedulingProgress(SchedulingPhase phase, boolean feasible) {}

View File

@@ -0,0 +1,59 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Scores match the solver objectives; lower values are better. */
public record SchedulingSummary(long attendanceExcess, int performanceSpread, long varietyPenalty) {
public static SchedulingSummary of(final SchedulingInput input, final SchedulingResult result) {
final List<List<Long>> parts = result.assignments();
final Map<Long, GroupRequirement> groups = new HashMap<>();
input.groups().forEach((final GroupRequirement group) -> groups.put(group.groupId(), group));
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
return new SchedulingSummary(attendance(input, parts), max - min, variety(parts, groups));
}
private static long attendance(final SchedulingInput input, final List<List<Long>> parts) {
final Set<Long> showcases = new HashSet<>();
final Map<Long, Set<Integer>> attendance = new HashMap<>();
for (final GroupRequirement group : input.groups()) {
if (group.showcase()) {
showcases.addAll(group.dancerIds());
} else {
recordAttendance(group, parts, attendance);
}
}
showcases.removeAll(input.coachDancerIds());
return showcases.stream()
.mapToLong((final Long dancer) ->
Math.max(0, attendance.getOrDefault(dancer, Set.of()).size() - input.preferredDayParts()))
.sum();
}
private static void recordAttendance(
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
for (int part = 0; part < parts.size(); part++) {
if (parts.get(part).contains(group.groupId())) {
for (final long dancer : group.dancerIds()) {
attendance
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
.add(part);
}
}
}
}
private static long variety(final List<List<Long>> parts, final Map<Long, GroupRequirement> groups) {
long penalty = 0;
for (final List<Long> part : parts) {
for (int index = 1; index < part.size(); index++) {
penalty += VarietyScore.penalty(groups.get(part.get(index - 1)), groups.get(part.get(index)));
}
}
return penalty;
}
}

View File

@@ -0,0 +1,41 @@
package nl.agilecc.agiledancers.rest;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.subscription.BackPressureStrategy;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Duration;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView;
import nl.agilecc.agiledancers.service.SchedulingRuns;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import org.jboss.resteasy.reactive.RestStreamElementType;
@Path("/api/scheduling/events")
public class SchedulingEventsResource {
private @Inject SchedulingRuns scheduling;
@GET
@Produces(MediaType.SERVER_SENT_EVENTS)
@RestStreamElementType(MediaType.APPLICATION_JSON)
public Multi<SchedulingEventView> events() {
final Multi<SchedulingEventView> updates = Multi.createFrom()
.emitter(
(final io.smallrye.mutiny.subscription.MultiEmitter<? super SchedulingEventView> emitter) -> {
final Runnable unsubscribe = scheduling.subscribe(
(final List<SchedulingRun> runs) -> emitter.emit(new SchedulingEventView(runs)));
emitter.onTermination(unsubscribe);
},
BackPressureStrategy.LATEST);
// Heartbeats keep idle proxy connections alive without changing the current snapshot.
final Multi<SchedulingEventView> heartbeat = Multi.createFrom()
.ticks()
.every(Duration.ofSeconds(15))
.onItem()
.transform((final Long tick) -> new SchedulingEventView(null));
return Multi.createBy().merging().streams(updates, heartbeat);
}
}

View File

@@ -18,15 +18,14 @@ import jakarta.ws.rs.core.UriInfo;
import java.util.List; import java.util.List;
import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show; import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest; import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest; import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest; import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView; import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.service.SchedulingService; import nl.agilecc.agiledancers.service.SchedulingRuns;
import nl.agilecc.agiledancers.service.SchoolManagementService; import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules; import nl.agilecc.agiledancers.service.ShowSchedules;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
@Path("/api/dance-schools/{schoolId}/shows") @Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@@ -34,7 +33,7 @@ import nl.agilecc.agiledancers.service.ShowSchedules;
public class ShowResource { public class ShowResource {
private @Inject SchoolManagementService service; private @Inject SchoolManagementService service;
private @Inject SchedulingService scheduling; private @Inject SchedulingRuns scheduling;
private @Inject ShowSchedules schedules; private @Inject ShowSchedules schedules;
@@ -105,10 +104,14 @@ public class ShowResource {
@POST @POST
@Path("/{id}/schedule") @Path("/{id}/schedule")
@Consumes(MediaType.WILDCARD) @Consumes(MediaType.WILDCARD)
public SchedulingView schedule(final @PathParam("id") long id) { public Response schedule(final @PathParam("id") long id) {
final SchedulingResult result = scheduling.schedule(schoolId, id); return Response.accepted(scheduling.start(schoolId, id)).build();
return new SchedulingView( }
result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven");
@GET
@Path("/{id}/schedule")
public SchedulingRun schedulingStatus(final @PathParam("id") long id) {
return scheduling.status(schoolId, id);
} }
@DELETE @DELETE

View File

@@ -0,0 +1,11 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.List;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
/** A null runs field is a heartbeat, not a replacement of the current snapshot. */
public record SchedulingEventView(List<SchedulingRun> runs) {
public SchedulingEventView {
runs = runs == null ? null : List.copyOf(runs);
}
}

View File

@@ -0,0 +1,17 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
/** Carries a stable calculation outcome independently of its human-readable explanation. */
public class SchedulingFailure extends ManagementException {
private final SchedulingFailureKind outcome;
public SchedulingFailure(final SchedulingFailureKind outcome, final String message) {
super(ManagementExceptionKind.INVALID_INPUT, message);
this.outcome = outcome;
}
public SchedulingFailureKind getOutcome() {
return outcome;
}
}

View File

@@ -0,0 +1,131 @@
package nl.agilecc.agiledancers.service;
import jakarta.annotation.PreDestroy;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import nl.agilecc.agiledancers.service.model.SchedulingState;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
/** Owns background tasks and publishes current state independently of any HTTP connection. */
@Singleton
public class SchedulingRuns {
private final SchoolManagementService schools;
private final SchedulingEngine engine;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Map<Long, SchedulingRun> runs = new HashMap<>();
private final Map<Object, Consumer<List<SchedulingRun>>> listeners = new HashMap<>();
@Inject
public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) {
this.schools = schools;
this.engine = engine;
}
public synchronized SchedulingRun start(final long schoolId, final long showId) {
final SchedulingInput input;
final SchedulingRun run;
synchronized (schools) {
final String name = schools.show(schoolId, showId).getName();
input = schools.beginScheduling(schoolId, showId);
run = SchedulingRun.started(schoolId, showId, name);
}
publish(run);
try {
executor.submit(() -> calculate(run, input));
} catch (final RuntimeException failure) {
schools.endScheduling(showId);
publish(run.completed(
SchedulingState.SOLVER_FAILED, "Berekening kon niet starten. Bestaande indeling behouden.", null));
throw failure;
}
return run;
}
public synchronized SchedulingRun status(final long schoolId, final long showId) {
schools.show(schoolId, showId);
return runs.get(showId);
}
/** Registration and initial snapshot share the publication lock, preventing a missed update. */
public synchronized Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
final Object key = new Object();
listeners.put(key, listener);
listener.accept(List.copyOf(runs.values()));
return () -> unsubscribe(key);
}
private synchronized void unsubscribe(final Object key) {
listeners.remove(key);
}
private synchronized void publish(final SchedulingRun run) {
runs.put(run.showId(), run);
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
listeners.values().forEach((final Consumer<List<SchedulingRun>> listener) -> listener.accept(snapshot));
}
private synchronized void progress(final SchedulingRun run, final SchedulingProgress update) {
publish(runs.get(run.showId()).progressed(update));
}
private void calculate(final SchedulingRun run, final SchedulingInput input) {
SchedulingRun completed;
try {
final SchedulingResult result =
engine.solve(input, (final SchedulingProgress update) -> progress(run, update));
final SchedulingSummary summary = SchedulingSummary.of(input, result);
schools.finishScheduling(run.schoolId(), input, result);
completed = latest(run)
.completed(
result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID,
result.optimal()
? "Optimaal conceptrooster."
: "Geldig conceptrooster; optimaliteit niet bewezen.",
summary);
} catch (final ManagementException failure) {
completed = failed(run, failure);
} catch (final RuntimeException | LinkageError failure) {
completed = latest(run)
.completed(SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null);
}
finish(completed);
}
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
final SchedulingState outcome = failure instanceof final SchedulingFailure scheduling
? switch (scheduling.getOutcome()) {
case INFEASIBLE -> SchedulingState.INFEASIBLE;
case NO_SOLUTION -> SchedulingState.NO_SOLUTION;
case SOLVER_FAILED -> SchedulingState.SOLVER_FAILED;
}
: (failure.getKind() == ManagementExceptionKind.CONFLICT
? SchedulingState.STALE
: SchedulingState.INVALID_INPUT);
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
}
private synchronized SchedulingRun latest(final SchedulingRun run) {
return runs.get(run.showId());
}
private synchronized void finish(final SchedulingRun completed) {
schools.endScheduling(completed.showId());
publish(completed);
}
@PreDestroy
public void close() {
executor.shutdownNow();
}
}

View File

@@ -0,0 +1,63 @@
package nl.agilecc.agiledancers.service.model;
import java.time.Instant;
import java.util.UUID;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */
public record SchedulingRun(
UUID runId,
long schoolId,
long showId,
String showName,
Instant startedAt,
Instant finishedAt,
SchedulingState state,
SchedulingProgress progress,
String message,
SchedulingSummary summary) {
public static SchedulingRun started(final long schoolId, final long showId, final String name) {
return new SchedulingRun(
UUID.randomUUID(),
schoolId,
showId,
name,
Instant.now(),
null,
SchedulingState.RUNNING,
new SchedulingProgress(SchedulingPhase.PREPARING, false),
"Berekening gestart.",
null);
}
public SchedulingRun progressed(final SchedulingProgress update) {
return new SchedulingRun(
runId,
schoolId,
showId,
showName,
startedAt,
null,
state,
new SchedulingProgress(update.phase(), progress.feasible() || update.feasible()),
message,
null);
}
public SchedulingRun completed(
final SchedulingState outcome, final String explanation, final SchedulingSummary scores) {
return new SchedulingRun(
runId,
schoolId,
showId,
showName,
startedAt,
Instant.now(),
outcome,
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
explanation,
scores);
}
}

View File

@@ -0,0 +1,12 @@
package nl.agilecc.agiledancers.service.model;
public enum SchedulingState {
RUNNING,
OPTIMAL,
VALID,
STALE,
INVALID_INPUT,
INFEASIBLE,
NO_SOLUTION,
SOLVER_FAILED
}

View File

@@ -1,9 +1,15 @@
package nl.agilecc.agiledancers.service.port; package nl.agilecc.agiledancers.service.port;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
public interface SchedulingEngine { public interface SchedulingEngine {
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */ /** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
public SchedulingResult solve(final SchedulingInput input); public SchedulingResult solve(final SchedulingInput input);
/** Reports phase changes and solution discovery while calculating. */
public default SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
return solve(input);
}
} }

View File

@@ -7,10 +7,13 @@ import com.google.ortools.sat.CpSolverStatus;
import com.google.ortools.sat.LinearExpr; import com.google.ortools.sat.LinearExpr;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.ManagementException; import nl.agilecc.agiledancers.service.SchedulingFailure;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.port.SchedulingEngine; import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton @Singleton
@@ -28,6 +31,11 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
@Override @Override
public SchedulingResult solve(final SchedulingInput input) { public SchedulingResult solve(final SchedulingInput input) {
return solve(input, (final SchedulingProgress progress) -> {});
}
@Override
public SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L); final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
Loader.loadNativeLibraries(); Loader.loadNativeLibraries();
final CpModel model = new CpModel(); final CpModel model = new CpModel();
@@ -38,7 +46,13 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
solver.getParameters().setCatchSigintSignal(false); solver.getParameters().setCatchSigintSignal(false);
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
SchedulingResult best = null; SchedulingResult best = null;
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) { final List<LinearExpr> objectives = List.of(attendance, LinearExpr.term(vars.spread(), 1), variety);
final List<SchedulingPhase> phases =
List.of(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY);
for (int stage = 0; stage < objectives.size(); stage++) {
final LinearExpr objective = objectives.get(stage);
final SchedulingPhase phase = phases.get(stage);
observer.accept(new SchedulingProgress(phase, best != null));
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0; final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
if (remaining <= 0) { if (remaining <= 0) {
if (best != null) { if (best != null) {
@@ -48,7 +62,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
} }
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining)); solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
model.minimize(objective); model.minimize(objective);
final CpSolverStatus status = solver.solve(model); final CpSolverStatus status = solver.solve(model, new SchedulingSolutionObserver(observer, phase));
if (status == CpSolverStatus.UNKNOWN && best != null) { if (status == CpSolverStatus.UNKNOWN && best != null) {
return new SchedulingResult(false, best.assignments()); return new SchedulingResult(false, best.assignments());
} }
@@ -64,8 +78,12 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
private static void requireSolution(final CpSolverStatus status) { private static void requireSolution(final CpSolverStatus status) {
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) { if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
throw new ManagementException( throw new SchedulingFailure(
ManagementExceptionKind.INVALID_INPUT, switch (status) {
case INFEASIBLE -> SchedulingFailureKind.INFEASIBLE;
case UNKNOWN -> SchedulingFailureKind.NO_SOLUTION;
default -> SchedulingFailureKind.SOLVER_FAILED;
},
switch (status) { switch (status) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved."; case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN -> case UNKNOWN ->

View File

@@ -0,0 +1,26 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.sat.CpSolverSolutionCallback;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
/** Publish only the first feasible solution in a phase, avoiding an event per solver improvement. */
public class SchedulingSolutionObserver extends CpSolverSolutionCallback {
private final Consumer<SchedulingProgress> observer;
private final SchedulingPhase phase;
private boolean reported;
public SchedulingSolutionObserver(final Consumer<SchedulingProgress> observer, final SchedulingPhase phase) {
this.observer = observer;
this.phase = phase;
}
@Override
public void onSolutionCallback() {
if (!reported) {
reported = true;
observer.accept(new SchedulingProgress(phase, true));
}
}
}

View File

@@ -92,7 +92,8 @@ class S18ResourceTest {
final long dancer = dancer(school, "Coach"); final long dancer = dancer(school, "Coach");
group(school, dancer); group(school, dancer);
final String show = show(school); final String show = show(school);
given().post(show + "/schedule").then().statusCode(200); given().post(show + "/schedule").then().statusCode(202);
awaitScheduling(show);
given().post(show + "/finalize").then().statusCode(200); given().post(show + "/finalize").then().statusCode(200);
final long coach = given().contentType("application/json") final long coach = given().contentType("application/json")
.body(Map.of("dancerId", dancer)) .body(Map.of("dancerId", dancer))
@@ -133,4 +134,13 @@ class S18ResourceTest {
given().delete(school).then().statusCode(204); given().delete(school).then().statusCode(204);
} }
} }
private static void awaitScheduling(final String show) {
final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state"))
&& System.nanoTime() < deadline) {
java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20));
}
given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL"));
}
} }

View File

@@ -0,0 +1,141 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import org.junit.jupiter.api.Test;
@QuarkusTest
class SchedulingEventsTest {
private @Inject SchoolManagementService schools;
private @Inject ObjectMapper json;
private @TestHTTPResource URI base;
public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception {
final long school = schools.createSchool("SSE school").getId();
try {
final Show show = show(school);
final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule";
given().get(path).then().statusCode(204);
try (final HttpClient client = HttpClient.newHttpClient();
final ExecutorService executor = Executors.newSingleThreadExecutor();
final InputStream stream = stream(client);
final BufferedReader reader =
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
assertNotNull(next(reader).get("runs"));
final String runId = given().post(path)
.then()
.statusCode(202)
.extract()
.jsonPath()
.getString("runId");
final JsonNode completion =
executor.submit(() -> completion(reader, runId)).get(15, TimeUnit.SECONDS);
assertEquals("OPTIMAL", completion.get("state").asText());
assertTrue(completion.get("progress").get("feasible").asBoolean());
given().get(path).then().statusCode(200);
}
assertReconnectContains(show.getId());
} finally {
schools.deleteSchool(school);
}
}
private void assertReconnectContains(final long showId) throws Exception {
try (final HttpClient client = HttpClient.newHttpClient();
final InputStream stream = stream(client);
final BufferedReader reader =
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
final JsonNode runs = next(reader).get("runs");
boolean found = false;
for (final JsonNode run : runs) {
if (run.get("showId").asLong() == showId) {
assertEquals("OPTIMAL", run.get("state").asText());
found = true;
}
}
assertTrue(found);
}
}
private InputStream stream(final HttpClient client) throws Exception {
final HttpResponse<InputStream> response = client.send(
HttpRequest.newBuilder(base.resolve("/api/scheduling/events"))
.header("Accept", "text/event-stream")
.build(),
HttpResponse.BodyHandlers.ofInputStream());
assertEquals(200, response.statusCode());
assertTrue(response.headers().firstValue("content-type").orElse("").startsWith("text/event-stream"));
return response.body();
}
private JsonNode completion(final BufferedReader reader, final String runId) throws Exception {
while (true) {
final JsonNode event = next(reader);
if (event.get("runs").isNull()) {
continue;
}
for (final JsonNode run : event.get("runs")) {
if (run.get("runId").asText().equals(runId)
&& !run.get("state").asText().equals("RUNNING")) {
return run;
}
}
}
}
private JsonNode next(final BufferedReader reader) throws Exception {
String line = reader.readLine();
while (line != null && !line.startsWith("data:")) {
line = reader.readLine();
}
assertNotNull(line);
return json.readTree(line.substring(5).trim());
}
private Show show(final long school) {
final long group = schools.createGroup(
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
.getId();
schools.addGroupDancer(
school,
group,
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
return schools.createShow(
school,
"Show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
}
}

View File

@@ -0,0 +1,61 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.service.SchedulingFailure;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingProgressTest {
public @Test void realSolverReportsDiscoveryDuringEachPhase() {
final SchedulingInput input = new SchedulingInput(1, 0, 2, List.of(new GroupRequirement(2, 1)));
final List<SchedulingProgress> updates = new ArrayList<>();
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input, updates::add);
assertTrue(result.optimal());
assertEquals(
List.of(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY),
updates.stream().map(SchedulingProgress::phase).distinct().toList());
assertEquals(new SchedulingProgress(SchedulingPhase.ATTENDANCE, false), updates.getFirst());
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.ATTENDANCE, true)));
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.VARIETY, true)));
}
public @Test void noSolutionAndInfeasibleHaveStableDistinctOutcomes() {
final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 1)));
assertEquals(
SchedulingFailureKind.NO_SOLUTION,
assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine(0).solve(input))
.getOutcome());
final SchedulingInput impossible = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 2)));
assertEquals(
SchedulingFailureKind.INFEASIBLE,
assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine().solve(impossible))
.getOutcome());
}
public @Test void summaryMatchesTheThreeObjectiveDefinitionsAndCoachExemptions() {
final GroupRequirement showcase = new GroupRequirement(1, 1, true, Set.of(10L, 11L), Set.of(), 20);
final GroupRequirement regular =
new GroupRequirement(2, 2, false, Set.of(10L, 11L), Set.of(DanceGroupStyle.MODERN), 22);
final SchedulingInput input = new SchedulingInput(1, 0, 3, List.of(showcase, regular), 1, Set.of(11L));
final SchedulingResult result = new SchedulingResult(false, List.of(List.of(1L, 2L), List.of(2L), List.of()));
final SchedulingSummary summary = SchedulingSummary.of(input, result);
assertEquals(1, summary.attendanceExcess());
assertEquals(2, summary.performanceSpread());
assertEquals(VarietyScore.penalty(showcase, regular), summary.varietyPenalty());
}
}

View File

@@ -175,7 +175,8 @@ class SchoolManagementResourceTest {
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer) given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
.then() .then()
.statusCode(204); .statusCode(204);
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true)); given().post(show + "/schedule").then().statusCode(202).body("state", equalTo("RUNNING"));
awaitScheduling(show);
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group)); given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
final String before = given().get(show).asString(); final String before = given().get(show).asString();
for (final String count : new String[] {"0", "2", "1.5", "7"}) { for (final String count : new String[] {"0", "2", "1.5", "7"}) {
@@ -291,4 +292,13 @@ class SchoolManagementResourceTest {
private static ValidatableResponse put(final String path, final Object body) { private static ValidatableResponse put(final String path, final Object body) {
return given().contentType("application/json").body(body).put(path).then(); return given().contentType("application/json").body(body).put(path).then();
} }
private static void awaitScheduling(final String show) {
final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state"))
&& System.nanoTime() < deadline) {
java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20));
}
given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL"));
}
} }

View File

@@ -0,0 +1,182 @@
package nl.agilecc.agiledancers.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import nl.agilecc.agiledancers.service.model.SchedulingState;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingRunsTest {
private final SchoolManagementService schools = new SchoolManagementService();
private final long school = schools.createSchool("School").getId();
public @Test void startsInBackgroundRejectsDuplicatesAndRecoversCurrentStatus() throws Exception {
final Show show = show();
final var entered = new CountDownLatch(1);
final var release = new CountDownLatch(1);
final var updates = new AtomicReference<List<SchedulingRun>>();
final var runs = new SchedulingRuns(schools, blocking(entered, release));
try {
final Runnable unsubscribe = runs.subscribe(updates::set);
assertTrue(updates.get().isEmpty());
final SchedulingRun started = runs.start(school, show.getId());
assertEquals(SchedulingState.RUNNING, started.state());
assertTrue(entered.await(10, TimeUnit.SECONDS));
assertEquals(started, runs.status(school, show.getId()));
assertThrows(ManagementException.class, () -> runs.start(school, show.getId()));
unsubscribe.run();
final var reconnect = new AtomicReference<List<SchedulingRun>>();
final Runnable disconnect = runs.subscribe(reconnect::set);
assertEquals(started, reconnect.get().getFirst());
release.countDown();
final SchedulingRun finished = await(runs, show);
assertEquals(SchedulingState.OPTIMAL, finished.state());
assertEquals(0, finished.summary().performanceSpread());
assertTrue(finished.progress().feasible());
assertTrue(finished.finishedAt().compareTo(finished.startedAt()) >= 0);
assertFalse(show.getDayParts().getFirst().getDanceGroups().isEmpty());
assertEquals(SchedulingState.RUNNING, updates.get().getFirst().state());
disconnect.run();
assertNotEquals(started.runId(), runs.start(school, show.getId()).runId());
await(runs, show);
} finally {
release.countDown();
runs.close();
}
}
public @Test void differentShowsRunConcurrentlyAndEditsRejectStaleResults() throws Exception {
final Show first = show();
final Show second = show();
final var entered = new CountDownLatch(2);
final var release = new CountDownLatch(1);
final var runs = new SchedulingRuns(schools, blocking(entered, release));
try {
runs.start(school, first.getId());
runs.start(school, second.getId());
assertTrue(entered.await(10, TimeUnit.SECONDS));
schools.touch(first);
release.countDown();
assertEquals(SchedulingState.STALE, await(runs, first).state());
assertTrue(first.getDayParts().getFirst().getDanceGroups().isEmpty());
assertEquals(SchedulingState.OPTIMAL, await(runs, second).state());
} finally {
release.countDown();
runs.close();
}
}
public @Test void failuresRemainVisibleAndReleaseTheShowForRetry() {
final Show show = show();
final List<SchedulingEngine> engines = new ArrayList<>();
engines.add((final SchedulingInput input) -> {
throw new IllegalStateException("failure");
});
engines.add((final SchedulingInput input) -> {
throw new UnsatisfiedLinkError("native");
});
engines.add((final SchedulingInput input) -> null);
for (final SchedulingFailureKind kind : SchedulingFailureKind.values()) {
engines.add((final SchedulingInput input) -> {
throw new SchedulingFailure(kind, "Explained outcome");
});
}
for (final SchedulingEngine engine : engines) {
assertFailure(show, engine);
}
}
private void assertFailure(final Show show, final SchedulingEngine engine) {
final var runs = new SchedulingRuns(schools, engine);
try {
runs.start(school, show.getId());
final SchedulingRun failed = await(runs, show);
assertNotEquals(SchedulingState.RUNNING, failed.state());
assertTrue(failed.message().contains("behouden"));
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
} finally {
runs.close();
}
}
public @Test void validUnprovenResultIsAppliedAndShutdownRejectsNewWorkWithoutLeakingGuard() {
final Show show = show();
final SchedulingEngine engine = (final SchedulingInput input) -> new SchedulingResult(
false, new OrToolsSchedulingEngine().solve(input).assignments());
final var runs = new SchedulingRuns(schools, engine);
runs.start(school, show.getId());
assertEquals(SchedulingState.VALID, await(runs, show).state());
runs.close();
assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> runs.start(school, show.getId()));
assertEquals(
SchedulingState.SOLVER_FAILED, runs.status(school, show.getId()).state());
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
}
private Show show() {
final long group = schools.createGroup(
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
.getId();
schools.addGroupDancer(
school,
group,
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
return schools.createShow(
school,
"Show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
}
private SchedulingEngine blocking(final CountDownLatch entered, final CountDownLatch release) {
return (final SchedulingInput input) -> {
entered.countDown();
try {
assertTrue(release.await(10, TimeUnit.SECONDS));
} catch (final InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
// The real solver's callbacks are covered separately; exercise the default port overload here.
return new OrToolsSchedulingEngine().solve(input);
};
}
private SchedulingRun await(final SchedulingRuns runs, final Show show) {
final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
SchedulingRun status = runs.status(school, show.getId());
while (status.state().equals(SchedulingState.RUNNING) && System.nanoTime() < deadline) {
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
status = runs.status(school, show.getId());
}
assertNotEquals(SchedulingState.RUNNING, status.state());
return status;
}
}

View File

@@ -143,8 +143,13 @@ test('manages a school, shared memberships and shows through the real REST API',
await page.getByRole('button', { name: /Schedule groups/ }).click(); await page.getByRole('button', { name: /Schedule groups/ }).click();
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible(); await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await expect( await expect(
page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }), page.locator('.scheduling-progress').filter({ hasText: 'Optimaal conceptrooster.' }),
).toBeVisible(); ).toBeVisible();
await expect(page.locator('.scheduling-snackbar')).toContainText('Zomershow');
await expect(page.locator('.scheduling-progress')).toContainText('Spreiding:');
await page.reload();
await expect(page.locator('.scheduling-progress')).toContainText('Optimaal conceptrooster.');
await expect(page.locator('.scheduling-snackbar')).toHaveCount(0);
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click(); await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
const scheduleDialog = page.getByRole('dialog'); const scheduleDialog = page.getByRole('dialog');
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click(); await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();

View File

@@ -5,6 +5,10 @@ server {
index index.html; index index.html;
location /api/ { location /api/ {
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 1860s; proxy_read_timeout 1860s;
proxy_send_timeout 1860s; proxy_send_timeout 1860s;
proxy_pass http://${BACKEND_UPSTREAM}; proxy_pass http://${BACKEND_UPSTREAM};

View File

@@ -11,7 +11,10 @@ export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideHttpClient(), provideHttpClient(),
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })), provideRouter(
routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' }),
),
{ provide: LOCALE_ID, useValue: 'nl-NL' }, { provide: LOCALE_ID, useValue: 'nl-NL' },
], ],
}; };

View File

@@ -237,3 +237,30 @@ main {
margin: 0 20px; margin: 0 20px;
} }
} }
.scheduling-snackbar {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1000;
display: flex;
align-items: center;
gap: 1rem;
max-width: min(40rem, calc(100vw - 3rem));
padding: 1rem 1.5rem;
background: #193b33;
color: white;
border-radius: 0.75rem;
box-shadow: 0 0.25rem 1rem #0003;
}
.scheduling-snackbar a {
color: white;
text-decoration: underline;
}
.scheduling-snackbar button {
background: transparent;
color: white;
border: 0;
cursor: pointer;
font-size: 1.5rem;
}

View File

@@ -44,3 +44,19 @@
</footer> </footer>
</div> </div>
</div> </div>
@if (scheduling.notifications()[0]; as run) {
<aside class="scheduling-snackbar" role="status" aria-live="polite">
<span
><strong>{{ run.showName }}</strong
>: {{ run.message }}</span
>
<a
[routerLink]="['/schools', run.schoolId, 'shows']"
[fragment]="'show-' + run.showId"
(click)="scheduling.dismiss()"
>Bekijk show</a
>
<button type="button" aria-label="Melding sluiten" (click)="scheduling.dismiss()">×</button>
</aside>
}

View File

@@ -1,12 +1,17 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { App } from './app'; import { App } from './app';
import { SchedulingStatus } from './core/scheduling-status';
import { signal } from '@angular/core';
describe('Application shell', () => { describe('Application shell', () => {
it('provides navigation and a keyboard skip link', async () => { it('provides navigation and a keyboard skip link', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideRouter([])], providers: [
provideRouter([]),
{ provide: SchedulingStatus, useValue: { connect: vi.fn(), notifications: signal([]) } },
],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
await fixture.whenStable(); await fixture.whenStable();

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core'; import { SchedulingStatus } from './core/scheduling-status';
import { Component, inject, OnInit } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router'; import { RouterLink, RouterOutlet } from '@angular/router';
@Component({ @Component({
@@ -7,4 +8,10 @@ import { RouterLink, RouterOutlet } from '@angular/router';
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css', styleUrl: './app.css',
}) })
export class App {} export class App implements OnInit {
readonly scheduling = inject(SchedulingStatus);
ngOnInit(): void {
this.scheduling.connect();
}
}

View File

@@ -0,0 +1,108 @@
import { TestBed } from '@angular/core/testing';
import { SchedulingRun, SchedulingState, SchedulingStatus } from './scheduling-status';
class FakeEventSource {
static current: FakeEventSource;
readonly close = vi.fn();
onmessage?: (event: { data: string }) => void;
onerror?: () => void;
constructor(readonly url: string) {
FakeEventSource.current = this;
}
send(runs: SchedulingRun[] | null): void {
this.onmessage?.({ data: JSON.stringify({ runs }) });
}
}
function run(state: SchedulingState, runId = 'run-1'): SchedulingRun {
return {
runId,
schoolId: 1,
showId: 2,
showName: 'Zomershow',
startedAt: '2026-11-03T12:00:00Z',
finishedAt: state === 'RUNNING' ? null : '2026-11-03T12:01:02Z',
state,
progress: { phase: 'VARIETY', feasible: true },
message: 'Afgerond',
summary: null,
};
}
describe('Scheduling status over SSE', () => {
let status: SchedulingStatus;
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('EventSource', FakeEventSource);
TestBed.configureTestingModule({});
status = TestBed.inject(SchedulingStatus);
status.connect();
});
afterEach(() => {
TestBed.resetTestingModule();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('recovers an existing outcome without replaying old notifications', () => {
expect(FakeEventSource.current.url).toBe('/api/scheduling/events');
FakeEventSource.current.send([run('OPTIMAL')]);
expect(status.run(1, 2)?.state).toBe('OPTIMAL');
expect(status.notifications()).toEqual([]);
expect(status.elapsed(run('OPTIMAL'))).toBe('1m 2s');
expect(status.phase(run('OPTIMAL'))).toBe('Variatie');
});
it('announces a completion missed during disconnection exactly once', () => {
FakeEventSource.current.send([run('RUNNING')]);
FakeEventSource.current.onerror?.();
expect(status.connected()).toBe(false);
const disconnected = FakeEventSource.current;
vi.advanceTimersByTime(3000);
expect(FakeEventSource.current).not.toBe(disconnected);
expect(disconnected.close).toHaveBeenCalledOnce();
expect(status.run(1, 2)?.state).toBe('RUNNING');
FakeEventSource.current.send([run('VALID')]);
expect(status.connected()).toBe(true);
expect(status.notifications()).toHaveLength(1);
status.dismiss();
FakeEventSource.current.send([run('VALID')]);
expect(status.notifications()).toEqual([]);
});
it('does not treat heartbeats or phase changes as completions', () => {
FakeEventSource.current.send(null);
FakeEventSource.current.send([run('OPTIMAL')]);
expect(status.notifications()).toEqual([]);
FakeEventSource.current.send([run('RUNNING', 'run-2')]);
FakeEventSource.current.send(null);
expect(status.run(1, 2)?.runId).toBe('run-2');
expect(status.notifications()).toEqual([]);
FakeEventSource.current.send([run('SOLVER_FAILED', 'run-2')]);
expect(status.notifications()).toHaveLength(1);
});
it('announces a locally started run completed before the first SSE snapshot', () => {
FakeEventSource.current.onerror?.();
status.started(run('RUNNING'));
vi.advanceTimersByTime(3000);
FakeEventSource.current.send([run('OPTIMAL'), run('VALID', 'historical')]);
expect(status.notifications().map((item) => item.runId)).toEqual(['run-1']);
status.dismiss();
FakeEventSource.current.onerror?.();
vi.advanceTimersByTime(3000);
FakeEventSource.current.send([run('OPTIMAL'), run('VALID', 'historical')]);
expect(status.notifications()).toEqual([]);
});
it('retains a fast completion when the start response arrives after SSE', () => {
FakeEventSource.current.send([]);
FakeEventSource.current.send([run('OPTIMAL')]);
status.started(run('RUNNING'));
expect(status.run(1, 2)?.state).toBe('OPTIMAL');
status.connect();
const source = FakeEventSource.current;
TestBed.resetTestingModule();
expect(source.close).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,124 @@
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
export type SchedulingState =
| 'RUNNING'
| 'OPTIMAL'
| 'VALID'
| 'STALE'
| 'INVALID_INPUT'
| 'INFEASIBLE'
| 'NO_SOLUTION'
| 'SOLVER_FAILED';
export type SchedulingPhase = 'PREPARING' | 'ATTENDANCE' | 'SPREAD' | 'VARIETY';
export interface SchedulingRun {
runId: string;
schoolId: number;
showId: number;
showName: string;
startedAt: string;
finishedAt: string | null;
state: SchedulingState;
progress: { phase: SchedulingPhase; feasible: boolean };
message: string;
summary: {
attendanceExcess: number;
performanceSpread: number;
varietyPenalty: number;
} | null;
}
@Injectable({ providedIn: 'root' })
export class SchedulingStatus {
private readonly destroyRef = inject(DestroyRef);
readonly runs = signal<SchedulingRun[]>([]);
readonly connected = signal(false);
readonly notifications = signal<SchedulingRun[]>([]);
readonly now = signal(Date.now());
private source?: EventSource;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private initialized = false;
private readonly seen = new Set<string>();
connect(): void {
if (this.source) return;
this.open();
const timer = setInterval(() => this.now.set(Date.now()), 1000);
this.destroyRef.onDestroy(() => {
this.source?.close();
clearTimeout(this.reconnectTimer);
clearInterval(timer);
});
}
private open(): void {
const source = new EventSource('/api/scheduling/events');
this.source = source;
source.onmessage = (event: MessageEvent<string>) => {
const eventData = JSON.parse(event.data) as { runs: SchedulingRun[] | null };
if (eventData.runs !== null) this.accept(eventData.runs);
this.connected.set(true);
};
source.onerror = () => {
this.connected.set(false);
// Retry also when a proxy error makes native EventSource reconnection stop permanently.
source.close();
clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => this.open(), 3000);
};
}
private accept(runs: SchedulingRun[]): void {
const activeRuns = new Set(
this.runs()
.filter((run) => run.state === 'RUNNING')
.map((run) => run.runId),
);
for (const run of runs) {
if (run.state === 'RUNNING' || this.seen.has(run.runId)) continue;
this.seen.add(run.runId);
if (this.initialized || activeRuns.has(run.runId))
this.notifications.update((items) => [...items, run]);
}
this.initialized = true;
this.runs.set(runs);
}
run(schoolId: number, showId: number): SchedulingRun | undefined {
return this.runs().find((run) => run.schoolId === schoolId && run.showId === showId);
}
started(run: SchedulingRun): void {
// The SSE completion can arrive before the POST response. Never overwrite it with RUNNING.
if (this.runs().some((item) => item.runId === run.runId)) return;
this.runs.update((items) => [...items.filter((item) => item.showId !== run.showId), run]);
}
elapsed(run: SchedulingRun): string {
const seconds = Math.max(
0,
Math.floor(
((run.finishedAt ? Date.parse(run.finishedAt) : this.now()) - Date.parse(run.startedAt)) /
1000,
),
);
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
phase(run: SchedulingRun): string {
return (
(
{
PREPARING: 'Voorbereiden',
ATTENDANCE: 'Aanwezigheid',
SPREAD: 'Spreiding',
VARIETY: 'Variatie',
} satisfies Record<SchedulingPhase, string>
)[run.progress.phase] ?? run.progress.phase
);
}
dismiss(): void {
this.notifications.update((items) => items.slice(1));
}
}

View File

@@ -73,11 +73,11 @@ describe('SchoolApi contract', () => {
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' }); .flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
expect(failure).toHaveBeenCalledOnce(); expect(failure).toHaveBeenCalledOnce();
}); });
it('schedules synchronously with room for the 30-minute solver limit', () => { it('starts a background calculation without a solver-length HTTP timeout', () => {
api.schedule(7, 3).subscribe(); api.schedule(7, 3).subscribe();
const request = http.expectOne('/api/dance-schools/7/shows/3/schedule'); const request = http.expectOne('/api/dance-schools/7/shows/3/schedule');
expect(request.request.method).toBe('POST'); expect(request.request.method).toBe('POST');
expect(request.request.timeout).toBe(1860000); expect(request.request.timeout).toBeUndefined();
request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' }); request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' });
}); });
}); });

View File

@@ -1,3 +1,4 @@
import { SchedulingRun } from './scheduling-status';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { forkJoin, Observable } from 'rxjs'; import { forkJoin, Observable } from 'rxjs';
@@ -47,12 +48,8 @@ export class SchoolApi {
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> { saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
return this.save(`${this.base}/${schoolId}/shows`, body, id); return this.save(`${this.base}/${schoolId}/shows`, body, id);
} }
schedule(schoolId: number, showId: number): Observable<{ optimal: boolean; message: string }> { schedule(schoolId: number, showId: number): Observable<SchedulingRun> {
return this.http.post<{ optimal: boolean; message: string }>( return this.http.post<SchedulingRun>(`${this.base}/${schoolId}/shows/${showId}/schedule`, null);
`${this.base}/${schoolId}/shows/${showId}/schedule`,
null,
{ timeout: 1860000 },
);
} }
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> { saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, { return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {

View File

@@ -344,7 +344,7 @@
</div> </div>
<div class="show-list"> <div class="show-list">
@for (show of shows(); track show.id) { @for (show of shows(); track show.id) {
<article class="show-card"> <article class="show-card" [id]="'show-' + show.id">
<div class="date-tile"> <div class="date-tile">
<strong>{{ firstDate(show) | date: 'dd' }}</strong <strong>{{ firstDate(show) | date: 'dd' }}</strong
><span>{{ firstDate(show) | date: 'MMM yyyy' }}</span> ><span>{{ firstDate(show) | date: 'MMM yyyy' }}</span>
@@ -372,6 +372,48 @@
: 'Geen geldige planning. Plan de groepen opnieuw in.' : 'Geen geldige planning. Plan de groepen opnieuw in.'
}} }}
</p> </p>
@if (schedulingStatus.run(context.school.id, show.id); as run) {
<div class="scheduling-progress" role="status">
@if (!schedulingStatus.connected()) {
<p>
Verbinding verbroken. Status mogelijk verouderd; de berekening loopt door.
</p>
}
@if (run.state === 'RUNNING') {
<p>
Bezig met inplannen · {{ schedulingStatus.phase(run) }} ·
{{ schedulingStatus.elapsed(run) }}
</p>
<p>
{{
run.progress.feasible
? 'Geldige oplossing gevonden; optimalisatie loopt door.'
: 'Nog geen geldige oplossing gevonden.'
}}
</p>
} @else {
<p>{{ run.message }}</p>
<p>
Afgerond: {{ run.finishedAt | date: 'dd-MM-yyyy HH:mm:ss' }} · Duur:
{{ schedulingStatus.elapsed(run) }}
</p>
@if (run.summary; as summary) {
<p>
Aanwezigheid: {{ summary.attendanceExcess }} extra dagdelen boven voorkeur
(showcasedansers, exclusief coaches).
</p>
<p>
Spreiding: {{ summary.performanceSpread }} optredens verschil tussen drukste
en rustigste dagdeel.
</p>
<p>
Variatie: {{ summary.varietyPenalty }} strafpunten voor opeenvolgende
groepen (lager is beter).
</p>
}
}
</div>
}
<button <button
type="button" type="button"
class="button primary" class="button primary"

View File

@@ -1,5 +1,5 @@
import { SchoolTransfer } from '../shared/school-transfer'; import { SchoolTransfer } from '../shared/school-transfer';
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core'; import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router'; import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
@@ -14,6 +14,7 @@ import {
Show, Show,
} from '../core/api-models'; } from '../core/api-models';
import { apiError } from '../core/api-error'; import { apiError } from '../core/api-error';
import { SchedulingStatus } from '../core/scheduling-status';
import { SchoolApi } from '../core/school-api'; import { SchoolApi } from '../core/school-api';
import { EntityEditor } from '../shared/entity-editor'; import { EntityEditor } from '../shared/entity-editor';
import { ConfirmDialog } from '../shared/confirm-dialog'; import { ConfirmDialog } from '../shared/confirm-dialog';
@@ -57,7 +58,30 @@ export class SchoolPage implements OnInit {
readonly scheduleEditing = signal<Show | null>(null); readonly scheduleEditing = signal<Show | null>(null);
readonly members = signal<DanceGroup | null>(null); readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null); readonly deleting = signal<DeleteTarget | null>(null);
readonly scheduling = signal<number[]>([]); readonly schedulingStatus = inject(SchedulingStatus);
private readonly starting = signal<number[]>([]);
readonly scheduling = computed(() => [
...this.starting(),
...this.schedulingStatus
.runs()
.filter((run) => run.schoolId === this.data()?.school.id && run.state === 'RUNNING')
.map((run) => run.showId),
]);
private readonly completed = new Set<string>();
constructor() {
effect(() => {
const schoolId = this.data()?.school.id;
let changed = false;
for (const run of this.schedulingStatus.runs()) {
if (run.schoolId !== schoolId || run.state === 'RUNNING' || this.completed.has(run.runId))
continue;
this.completed.add(run.runId);
changed = true;
}
if (changed) void this.reload();
});
}
readonly deleteBusy = signal(false); readonly deleteBusy = signal(false);
readonly deleteError = signal(''); readonly deleteError = signal('');
readonly search = signal(''); readonly search = signal('');
@@ -151,20 +175,15 @@ export class SchoolPage implements OnInit {
) )
) )
return; return;
this.scheduling.update((ids) => [...ids, show.id]); this.starting.update((ids) => [...ids, show.id]);
this.error.set(''); this.error.set('');
try { try {
const result = await firstValueFrom(this.api.schedule(schoolId, show.id)); const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
this.notice.set( this.schedulingStatus.started(result);
result.optimal
? 'Optimaal conceptrooster.'
: 'Geldig conceptrooster; optimaliteit niet bewezen.',
);
await this.reload();
} catch (error: unknown) { } catch (error: unknown) {
this.error.set(apiError(error)); this.error.set(apiError(error));
} finally { } finally {
this.scheduling.update((ids) => ids.filter((id) => id !== show.id)); this.starting.update((ids) => ids.filter((id) => id !== show.id));
} }
} }
async reload(): Promise<void> { async reload(): Promise<void> {

View File

@@ -90,6 +90,28 @@ Docker-locatie en `DOCKER_CONFIG` in. Gebruik een eigen `docker-auth`-submap.
Alleen de frontend bindt aan de private IPv4; de backend krijgt geen hostpoort. Alleen de frontend bindt aan de private IPv4; de backend krijgt geen hostpoort.
De Docker-projectnaam is vast `agiledancers-production`. De Docker-projectnaam is vast `agiledancers-production`.
### DSM-firewall
De bevestigde productieomgeving gebruikt frontendpoort **8088** op
`192.168.2.28` en Docker-subnet **172.18.0.0/16**. Maak in
**Configuratiescherm → Beveiliging → Firewall → Regels bewerken** toestaan-regels
op **Alle interfaces**, boven eventuele weigerregels:
| Poort | Protocol | Bron | Actie |
| --- | --- | --- | --- |
| 8088 | TCP | `192.168.2.0`, masker `255.255.255.0` | Toestaan |
| 8080 | TCP | `172.18.0.0`, masker `255.255.0.0` | Toestaan |
De eerste regel geeft LAN-clients toegang; de tweede laat de frontend de backend
bereiken. Voeg voor bestaande VPN-clients zo nodig hun eigen bron-subnet toe
aan de frontendregel. Er wordt geen backendpoort op de NAS gepubliceerd en de
firewall blijft aan. Controleer bij het opnieuw aanmaken van het Docker-netwerk
het werkelijke subnet met `docker network inspect` en pas de regel aan als dat
subnet wijzigt. Gewone deployments vervangen containers en behouden het netwerk.
De tijdelijke S6-acceptatieregel voor `172.20.0.0/16` mag na de geïsoleerde tests
weg; dat testnetwerk en de testcontainers zijn opgeruimd.
Als Docker sudo vereist, laat een beheerder met `visudo` een afzonderlijke Als Docker sudo vereist, laat een beheerder met `visudo` een afzonderlijke
sudoers-regel installeren en valideren, met het gecontroleerde Docker-pad: sudoers-regel installeren en valideren, met het gecontroleerde Docker-pad:

View File

@@ -1,8 +1,9 @@
# S6 implementation and operational evidence # S6 implementation and operational evidence
Status: IN_PROGRESS. Repository implementation and local validation do not yet Status: IN_REVIEW. Production `v1.0.0` is running on the Synology at
establish an operational Synology deployment. No production release tags have <http://192.168.2.28:8088>. Main publication, the real Gitea deployment and the
been created by this implementation session. isolated NAS recovery/rollback acceptance checks passed. The evidence below
distinguishes real production actions from deliberately isolated fault tests.
## Confirmed environment ## Confirmed environment
@@ -88,9 +89,129 @@ Image publication is skipped for the feature branch.
The subsequent evidence-only commit intentionally uses the accepted `[skip ci]` The subsequent evidence-only commit intentionally uses the accepted `[skip ci]`
marker; implementation code is unchanged from this successful run. marker; implementation code is unchanged from this successful run.
Main build/publication and dispatched production/recovery evidence remain to be Implementation [PR #12](http://192.168.2.28:3000/wanjakrah/agiledancers/pulls/12)
collected after review and the protected merge. Review PR: was reviewed and merged through the protected main branch. Production and
[S6 #12](http://192.168.2.28:3000/wanjakrah/agiledancers/pulls/12). post-merge acceptance evidence follow.
## Production deployment and native workflow guards
The main SHA is `b0d6592761cf3ceab6e1ffbf9ca50548584a0096`.
| Evidence | Gitea run | Result |
| --- | --- | --- |
| Main build/test/publication | [#37](http://192.168.2.28:3000/wanjakrah/agiledancers/actions/runs/37), API ID 38 | Success |
| First manual `new` / `patch` release | [#38](http://192.168.2.28:3000/wanjakrah/agiledancers/actions/runs/38), API ID 39 | Success; production `v1.0.0` |
| Deliberate duplicate new release for the same SHA | [#39](http://192.168.2.28:3000/wanjakrah/agiledancers/actions/runs/39), API ID 40 | Expected rejection, production unchanged |
| Manual deployment from a temporary validation branch | [#40](http://192.168.2.28:3000/wanjakrah/agiledancers/actions/runs/40), API ID 41 | Expected skip, production unchanged |
The original feature branch had been deleted after merge; its attempted dispatch
returned 404 and is not counted as branch-guard evidence. The actual skipped run
used `validation/s6-deploy-guard`, based on the existing documentation-only
`60496de` commit. That temporary branch was removed after verification.
The real annotated Git tag `v1.0.0` has tag-object SHA
`0b8df86a9de1729395baad35584e4accead29c44` and points directly to the main SHA above.
Gitea tag protection rule ID **1**, pattern `v*`, allows only `wanjakrah` (the
account owning the dedicated release token) to create/delete release tags.
The helper never moves or deletes them; this does not remove the trusted
maintainer's administrative ability to manage tags.
Production image digests, using the NAS-local `127.0.0.1:3000/wanjakrah/` registry:
| Component | Deployed digest |
| --- | --- |
| Backend | `sha256:1d6f5e35bb58341bfa7b6ed0b88e5bdebced60666ca289d5511613f35d9ee92e` |
| Frontend | `sha256:843a1ffcdbe708573ca55f51a04adee0d158f255f067225a20c6d41b382ce295` |
Both running containers report `v1.0.0` and the selected main SHA. Backend
readiness, frontend and proxied API checks passed in the workflow. A subsequent
read-only GET from the mini-pc to `http://192.168.2.28:8088/api/dance-schools`
returned HTTP success and `[]`. The successful state is recorded under
`releases/release-39-3b481acf-396d-41d6-96a6-6b7ef8710f18/`, with no pending
journal or deployment lock. As this is the first production release, its previous
successful production release is correctly `null`.
### Firewall and diagnostics
The initial readiness wait exposed an active DSM firewall blocking frontend-to-
backend traffic. The maintainer added these **All interfaces** allow rules above
deny rules: TCP **8088** from `192.168.2.0/24`, and TCP **8080** from the actual
production Docker subnet **172.18.0.0/16**. The same first deployment then passed;
there was no extra production version or forced bypass of readiness checks.
Downloaded and opened the actual workflow diagnostic ZIPs:
- `deployment-39-20261103135841995564629`: two files; recorded outcome `success`.
- `deployment-40-20261103141439174434089`: two files; recorded outcome `rejected`.
Both workflows request fourteen-day artifact retention, within the previously
confirmed server retention limits. The ZIPs and run-view metadata are retained
locally in `ci/artifacts/deployment/live-workflows/`. The older Gitea artifact
list API limitation documented under S4 was avoided by downloading the actual
run-page artifact archives.
## Isolated real NAS failure and rollback acceptance
**Twelve checks passed against real NAS Docker containers**, using the production
`Release` coordinator and remote shell script. They used only project
`agiledancers-s6-acceptance`, loopback endpoint `127.0.0.1:18089` and network
`172.20.0.0/16`. State/configuration was isolated under
`/volume1/docker/agiledancers-s6-acceptance`, with `-first` and `-recovery` sibling
directories for first-release and failed-recovery cases.
The adapter changed only the project/network/configuration namespace and injected
controlled faults. **Release tags and main-head selections in this test adapter
were virtual**: no additional real Git tags were created. Build verification and
image pulls used real Gitea/registry responses for verified main SHAs `b1544357...`
and `b0d65927...`. The missing-image case also used verified main SHA `c965f9b4...`.
Fault cases used a ten-second readiness window and healthy cases sixty seconds;
the production workflow retains its normal five-minute replacement bound.
1. A healthy initial isolated release passed the real container/readiness checks.
2. A distinct candidate whose backend deliberately exited with status 23 failed;
the real previous image pair was restored, the attempt remained failed, and
its virtual version remained allocated.
3. Retrying the same version/SHA with a healthy configuration succeeded without
allocating another version.
4. Manual rollback restored the distinct previous SHA without another version;
a second rollback restored the newer successful release.
5. Retry of an older version after rollback was rejected by the high-watermark.
6. A real missing registry image was rejected before version allocation or
replacement.
7. An unverified SHA was rejected using real Gitea build metadata.
8. Duplicate and stale releases were rejected while running containers stayed
unchanged.
9. A competing NAS lock owner excluded a second release attempt.
10. An injected lost state-commit response retained the real pending journal and
lock, blocked the next attempt and was resolved with explicit manual recovery.
11. A real failed first release retained its journal/lock; manual stop/archive
recovery left no fabricated successful state.
12. Both a candidate and its recovery configuration were deliberately broken;
automatic recovery failed visibly and retained its journal/lock. Repairing
the retained previous configuration and following manual recovery restored
the recorded previous SHA.
Raw program, logs and `results.json` are retained in
`ci/artifacts/deployment/nas-acceptance/`. The test stack's containers and Docker
network were removed. Test state/history remains for inspection. Final independent
verification confirmed that production still ran the original `v1.0.0` SHA,
without a pending journal or lock, and that no acceptance containers/network
remained. The maintainer was told the temporary firewall rule for `172.20.0.0/16`
can be removed; the two production rules must remain.
These isolated tests prove the NAS replacement/recovery behavior, not a second
successful production release. Native Gitea rollback against a populated
production predecessor can be exercised after a normal subsequent release;
the same coordinator's actual distinct-version rollback is demonstrated above.
## Data boundary
The maintainer explicitly accepted redeployment data loss for the launching-
partner phase until S16 and confirmed the operator-communication policy during
refinement. The workflow and runbook display that limitation. No messages were
sent to the partner on the maintainer's behalf. S16 is not implemented, so saved-
data preservation and migration compatibility remain conditional S16 acceptance,
not a durability claim for this release.
## Required operational acceptance ## Required operational acceptance
@@ -99,24 +220,25 @@ collected after review and the protected merge. Review PR:
- [x] Verify pinned SSH hostkey, key-only automation and separate registry pull - [x] Verify pinned SSH hostkey, key-only automation and separate registry pull
credential. Release-token read access is verified; actual tag-writing credential. Release-token read access is verified; actual tag-writing
acceptance awaits the first release. acceptance awaits the first release.
- [ ] Verify release-tag creation/protection with the dedicated token. - [x] Verify release-tag creation/protection with the dedicated token.
- [ ] Merge workflows through the protected-branch process and identify the - [x] Merge workflows through the protected-branch process and identify the
successful main build/publication run and SHA. successful main build/publication run and SHA.
- [ ] Demonstrate release form, first release tag and real Synology access; - [x] Demonstrate release form, first release tag and real Synology access;
record workflow URL, version, SHA and both deployed digests. record workflow URL, version, SHA and both deployed digests.
- [ ] Demonstrate rejected unverified/missing-image release and branch exclusion - [x] Demonstrate rejected unverified/missing-image release and branch exclusion
without modifying production. without modifying production.
- [ ] Demonstrate duplicate/stale/concurrent release protection, retained tags - [x] Demonstrate duplicate/stale/concurrent release protection, retained versions
after failure and exact-SHA retry without another version. after failure and exact-SHA retry without another version.
- [ ] Demonstrate failed candidate recovery while the workflow reports failure, - [x] Demonstrate failed candidate recovery while the coordinator reports failure,
with downloadable diagnostics. with downloadable diagnostics.
- [ ] Demonstrate manual rollback without a new version and retained image pairs. - [x] Demonstrate manual rollback without a new version and retained image pairs.
- [ ] Exercise first-release/recovery failure and interruption/manual recovery - [x] Exercise first-release/recovery failure and interruption/manual recovery
using isolated acceptance resources where appropriate. using isolated acceptance resources where appropriate.
- [ ] Confirm fourteen-day artifact retention. - [x] Confirm fourteen-day artifact retention configuration and artifact downloads.
- [ ] Confirm operator communication of the accepted pre-S16 data-loss policy. - [x] Confirm the maintainer's accepted pre-S16 data-loss/communication policy.
- [ ] Once S16 applies, verify data preservation and migration compatibility - [ ] Once S16 applies, verify data preservation and migration compatibility
during deployment and rollback (conditional on S16 implementation). during deployment and rollback (deferred until S16 implementation, not a
blocker for the explicitly accepted pre-S16 release).
Identify isolated acceptance resources explicitly. Simulated tests are not real Identify isolated acceptance resources explicitly. Simulated tests are not real
production evidence. production evidence.

84
specs/S12-validation.md Normal file
View File

@@ -0,0 +1,84 @@
# S12 implementation validation
## Delivered behavior
Scheduling starts through a short POST returning 202 and a run identifier.
The backend reserves and snapshots inputs before submitting background work;
completion applies a validated result or records a distinct unsuccessful outcome.
Per-show exclusion remains in the shared school service, with different shows
allowed to calculate concurrently. Status publication and subscription snapshots
share a lock so connecting cannot miss a transition between snapshot and subscription.
The solver publishes optimization phases and first feasible-solution discovery
through a callback. Intermediate assignments remain private. Final summaries
calculate the same attendance, spread and adjacency penalties as the solver.
SSE provides a current snapshot on connection and status changes, with separate
15-second heartbeats. The nginx proxy disables buffering. The frontend keeps one
application-wide connection, explicitly retries connection errors (including
permanent EventSource failures caused by proxy responses), and reconciles state
without event replay. Run identifiers deduplicate completion notifications.
## Validation
- Maven verify: 60 backend tests passed, plus 19 standards-checker regression tests.
- Backend line coverage: 1346/1384, or 97.25%, above the required 80%.
- Frontend: 30 unit tests, production build and ESLint.
- Playwright: three real REST/browser scenarios, including scheduling completion,
result summaries, a completion snackbar, reloading to recover the latest result
and suppression of old notifications after reload.
- HTTP SSE integration verifies initial snapshot, pushed completion and a new
connection recovering the completed run.
- Background tests cover duplicate exclusion, parallel shows, stale-result
rejection, preserved assignments, failure outcomes and retry after failure.
- Frontend tests cover missed completion after disconnect, reconnection, heartbeat
handling, deduplication, completion arriving before the POST response and a
locally started run completing before the first successful SSE connection.
- Real solver tests cover phase/discovery callbacks, distinct infeasible/no-solution
outcomes and objective-summary definitions including coach exemptions.
## Manual standards assessment
Scheduling states and phases use backend enums and frontend string unions while
preserving their JSON names. Initial SSE recovery notifies for locally tracked
running tasks while suppressing historical completions.
No waivers or weakened checks were introduced. The architecture tests pass.
New records contain immutable values; the SSE snapshot defensively copies its
list. The ten-component SchedulingRun record groups run identity, timestamps,
progress and outcome in one immutable snapshot, avoiding mutable task state in
API responses. Solver observers use the existing application-owned port and
framework-independent domain types; OR-Tools remains encapsulated in solver.
Retained review signals:
- SchedulingRuns fan-out (26): task orchestration uses JDK executor/collection
types, the scheduling port and immutable input/progress/result/status models.
Its injected dependencies remain two; splitting these short lifecycle methods
would distribute the shared publication and run-slot ordering.
- SchedulingEventsResource fan-out (16): SSE/Mutiny transport, JSON envelope and
service subscription types are confined to the REST adapter.
- OrToolsSchedulingEngine fan-out (21), solve cognitive complexity (11) and
length (41 code lines): the ordered three-stage loop retains deadline and
earlier-feasible-result fallback behavior while publishing phase/discovery.
requireSolution cyclomatic complexity (8) retains explicit mappings for stable
failure outcomes and their human-readable explanations.
- Integration/background test fan-out and the background lifecycle test's
34-line method reflect real HTTP/solver fixtures and lifecycle assertions.
Existing REST-test length signals remain below blocking limits after adapting
them to await background completion.
## Persistence follow-up in S16
The maintainer explicitly transferred durable scheduling status and
backend-restart recovery to S16 and approved completion of S12 with that scope
adjustment. S12 is IN_REVIEW in `in_review/S12_support-async-feedback-for-scheduling`;
implementation is ready for review and final acceptance is pending.
The application currently stores schools, saved assignments and the latest
scheduling status in memory. S16 now explicitly requires PostgreSQL storage of
accepted runs and their latest status, atomic durable result/status commits,
stable identifiers in recovered API/SSE snapshots and startup conversion of
previously running calculations to Interrupted without automatic resumption.
Restart/crash recovery tests belong to S16; the S12 validation above covers
navigation, browser reload and connection recovery within one backend lifetime.

View File

@@ -1,6 +1,6 @@
# S18 — Scheduling dance groups across a show # S18 — Scheduling dance groups across a show
Status: IN_REVIEW Status: IMPLEMENTED
As a dance school manager, As a dance school manager,
I want OR-Tools to schedule groups and order performances using participation, I want OR-Tools to schedule groups and order performances using participation,

View File

@@ -1,6 +1,6 @@
# S6 — Manual versioned production deployment through Gitea # S6 — Manual versioned production deployment through Gitea
Status: IN_PROGRESS Status: IMPLEMENTED
Agreed after the grill-me interview; shared understanding confirmed. Agreed after the grill-me interview; shared understanding confirmed.
@@ -141,44 +141,48 @@ does not restore lost data.
## Acceptance criteria ## Acceptance criteria
- [ ] Successful main builds still automatically verify and publish matching SHA - [x] Successful main builds still automatically verify and publish matching SHA
images; publishing does not automatically change production. images; publishing does not automatically change production.
- [ ] Gitea's manual workflow form provides new/retry mode and the appropriate - [x] Gitea's manual workflow form provides new/retry mode and the appropriate
release-type or existing-tag inputs; invalid combinations fail clearly. release-type or existing-tag inputs; invalid combinations fail clearly.
- [ ] The first release and subsequent patch/minor/major releases allocate the - [x] The first release and subsequent patch/minor/major releases allocate the
agreed versions and immutable annotated remote Git tags on verified SHAs. agreed versions and immutable annotated remote Git tags on verified SHAs.
- [ ] Duplicate new releases, tag conflicts, concurrent attempts, stale main - [x] Duplicate new releases, tag conflicts, concurrent attempts, stale main
snapshots and older pending deployments cannot overwrite release identity snapshots and older pending deployments cannot overwrite release identity
or replace a newer successful release. or replace a newer successful release.
- [ ] An unverified release or missing image is rejected before production changes; - [x] An unverified release or missing image is rejected before production changes;
branch builds cannot deploy production. branch builds cannot deploy production.
- [ ] A real Synology deployment serves the frontend and proxied API over the - [x] A real Synology deployment serves the frontend and proxied API over the
private endpoint, with both containers running the selected image pair. private endpoint, with both containers running the selected image pair.
- [ ] Readiness, frontend, proxied read-only API and image-identity checks establish - [x] Readiness, frontend, proxied read-only API and image-identity checks establish
success within five minutes or trigger recovery. success within five minutes or trigger recovery.
- [ ] A deliberately failed release restores the previous successful version, - [x] A deliberately failed release restores the previous successful version,
while the workflow reports failure. Failed first deployment and failed while the workflow reports failure. Failed first deployment and failed
recovery produce actionable diagnostics and manual recovery instructions. recovery produce actionable diagnostics and manual recovery instructions.
- [ ] A failed release can be retried with its existing tag/SHA without allocating - [x] A failed release can be retried with its existing tag/SHA without allocating
another version or superseding a newer successful release. another version or superseding a newer successful release.
- [ ] Manual rollback demonstrably restores the previous successful release with - [x] Manual rollback demonstrably restores the previous successful release with
no new version, and required retained images are available. no new version, and required retained images are available.
- [ ] The temporary pre-S16 data-loss policy is visible in workflow documentation - [x] The temporary pre-S16 data-loss policy is visible in workflow documentation
and the runbook. Once S16 applies, demonstrate saved data surviving both and the runbook. Once S16 applies, demonstrate saved data surviving both
deployment and rollback, including the migration compatibility requirement. deployment and rollback, including the migration compatibility requirement.
- [ ] Server setup and operational recovery documentation are usable; diagnostics The latter is conditional S16 acceptance and is not applicable yet.
- [x] Server setup and operational recovery documentation are usable; diagnostics
and release outcomes are retained for fourteen days without secrets. and release outcomes are retained for fourteen days without secrets.
## Implementation handoff ## Implementation and evidence
This specification records the confirmed design. Implementing workflows, release This specification records the confirmed design. The evidence combines automated
automation, Compose configuration, Synology provisioning and collecting actual behavior tests, real Gitea production release/guard runs and isolated real NAS
deployment/recovery evidence are subsequent work. Configuration alone does not fault tests. Their distinct scopes and the virtual-tag boundary of the isolated
tests are documented in `docs/s6-validation.md`. Configuration alone does not
complete S6. complete S6.
Implementation is in progress on `feature/S6_deployment`: workflows, Compose, Implementation PR #12 is merged. Production `v1.0.0` runs at
release/retry/rollback helpers, failure-path tests and provisioning/runbook are `http://192.168.2.28:8088` following a successful main build and real manual Gitea
provided. The dedicated NAS account, registry login and Gitea deployment secrets deployment. Native duplicate/branch guards and downloadable artifacts were
are provisioned. Real image preparation passed without a release or container verified. Twelve isolated real NAS checks cover recovery, retry, distinct-version
replacement. See `docs/s6-validation.md`; workflow execution and production rollback, failure guards and manual recovery; they use separate state/containers
deployment/recovery evidence remain required after the protected main merge. and virtual test tags, without changing production. The test resources have been
removed. See `docs/s6-validation.md` for run links, digests, evidence boundaries
and the conditional S16 data-persistence acceptance. S6 is ready for review.

View File

@@ -0,0 +1,144 @@
# S12 — Asynchronous feedback for scheduling
Status: IN_REVIEW
Agreed after the grill-me interview; shared understanding confirmed.
## User story
As a dance-school maintainer, I want feedback after starting Schedule Groups,
so that I can see whether the backend is still calculating, when it finishes
and what the outcome is, including when I leave and return to the show page.
## Background execution and concurrency
- Start scheduling in the backend without keeping the initiating HTTP request
open until the calculation finishes. Navigation, page reloads and disconnected
clients must not stop the calculation.
- The backend owns the current scheduling status for each show. All open
screens see the same status, including screens that did not start the run.
- Allow at most one active calculation per show. Disable Schedule Groups while
it is running and reject duplicate starts in the backend as well.
- Different shows may calculate concurrently. A global capacity limit and a
waiting queue are outside S12.
- Retain the existing 30-minute solver limit and scheduling rules.
## Progress and outcomes
- While running, show elapsed time, the current optimization phase (attendance,
spread or variety), and whether a valid solution has been found.
- Report a valid solution as soon as the solver discovers one, including during
a phase. Extend solver progress reporting rather than waiting for the phase
to finish. Do not apply intermediate solutions.
- Do not present a completion percentage or estimated finish time: the phases
do not represent proportional parts of the calculation time.
- Keep the latest status and outcome per show until a new calculation starts.
Show the finish time and distinguish an optimal solution from a valid solution
whose optimality was not proven within the time limit.
- For a successful result, show a concise summary with concrete results for
attendance, spread and variety, using understandable labels.
- Clearly distinguish invalid input, infeasibility, solver failure, time limit
without a valid solution and rejection because inputs changed. Explain why
no result was applied and that existing assignments were preserved.
Backend-restart interruption recovery belongs to S16.
## Applying results
- Retain the confirmation before starting that explains replacement of existing
assignments. A successful completed calculation automatically replaces them;
no preview/apply step is introduced.
- A valid solution may be applied when the time limit expires even if optimality
has not been proven.
- Preserve the existing scheduling input revision check. A result based on
outdated inputs must not replace the current assignments.
- Failed, infeasible, interrupted, stale and no-solution calculations preserve
existing assignments.
- Report success only after the resulting assignments have been saved. Keep
status and saved results consistent.
## Server-sent events and connection recovery
- Use server-sent events (SSE) for backend-to-frontend status updates.
- On initial connection and reconnection, provide the current backend status.
Correctness must not depend on receiving every previous event.
- Maintain updates across navigation within the application so completion
notifications can appear outside the show page.
- If the connection is lost, indicate that displayed status may be outdated
while the backend calculation continues. Reconnect and reconcile with the
current status when connectivity returns.
- Configure the application's proxy for streaming so updates reach the client
promptly rather than being buffered until completion.
## Completion notifications
- Show an application-wide snackbar when a calculation completes or fails,
including when the user has navigated to another application page.
- Include the show name, a short outcome and a View show action.
- Phase changes update progress without generating snackbars.
- After temporary connection loss, show a missed completion notification once
when the connection recovers. Reconnection must not repeatedly notify for
the same completion.
- Do not replay old snackbars on page reload or reopening the application;
show the retained status on the show page instead.
- Email, browser notifications and notifications outside the application are
outside S12.
## Persistence and backend restarts
The maintainer explicitly transferred durable scheduling status and recovery
following backend restarts to [S16](../refined/S16_store-state-in-persistent-storage)
when completing S12. The latest status and outcome in S12 survive navigation,
page reloads and SSE reconnection while the same backend process remains active.
Schools, assignments and scheduling status currently remain in memory.
S16 owns PostgreSQL storage of the latest run, atomic persistence of saved
assignments and completed status, and startup recovery of previously running
calculations as Interrupted without automatic resumption. Previously saved
results must survive restart under S16. These are not completion criteria for S12.
Distributed execution and scheduling on another PC remain in S13.
## Out of scope
- Cancelling a calculation or applying an intermediate solution.
- History of earlier calculations beyond the latest status and outcome.
- Global capacity limits, queues and remote workers.
- Changes to scheduling objectives or constraints.
- This specification grants no coding-standards waiver.
## Acceptance criteria
- [x] Starting Schedule Groups returns without waiting for solver completion;
leaving or reloading the page does not interrupt the calculation.
- [x] Opening a show in another screen recovers its current backend status;
duplicate runs for the same show are prevented in frontend and backend,
while different shows can run concurrently.
- [x] Running status shows elapsed time, phase and discovery of a valid solution
during a phase, without a fabricated completion percentage or ETA.
- [x] SSE delivers status changes promptly through the deployed proxy. Initial
connection and reconnection reconcile the current state, and loss of
connectivity visibly marks status as potentially outdated.
- [x] Successful runs automatically apply their result after the existing start
confirmation, retain stale-input protection and distinguish proven optimal
results from valid results at the time limit.
- [x] The final display includes finish time and concrete attendance, spread and
variety results when successful; unsuccessful outcomes are distinguishable
and explain that previous assignments were preserved.
- [x] An application-wide snackbar reports completion or failure with the show
name and View show action, including after navigation to another page.
- [x] Connection recovery notifies once for a missed completion; phase changes,
repeated reconnection and reopening do not replay old snackbars.
- [x] The latest status and outcome survive navigation, page reload and SSE
reconnection within the lifetime of the running backend. Durable storage
and recovery after backend restart are explicitly transferred to S16.
## Review handoff
Background execution, solver progress callbacks, SSE, frontend status recovery,
result summaries, proxy streaming configuration and application-wide completion
notifications are implemented and verified. S12 replaces S9's synchronous request
behavior while retaining scheduling and result-application protections.
See [S12 validation](../S12-validation.md) for verification and retained standards
signals. Durable storage and backend-restart recovery are tracked in S16 and do
not block review of this specification under the agreed scope adjustment.
Implementation is ready for review; final acceptance is pending.

View File

@@ -69,6 +69,34 @@ JSON remains the import/export format, rather than the primary storage model.
interrupted, rather than remain indefinitely marked as running. They do not interrupted, rather than remain indefinitely marked as running. They do not
resume automatically. Previously saved results remain available. resume automatically. Previously saved results remain available.
## Persistent scheduling status (transferred from S12)
The maintainer explicitly moved durable scheduling status and backend-restart
recovery from S12 to S16 when completing S12. S12 supplies background execution,
status snapshots, SSE and notifications within the lifetime of the backend.
- Persist the latest calculation per show in PostgreSQL, including its run
identifier, school/show identity, start and finish timestamps, state, latest
phase, whether a valid solution was found, outcome explanation and final
attendance/spread/variety summary when available. A run history is not required.
- Persist acceptance of a run before acknowledging its start. After restart or
crash, an accepted calculation must remain identifiable even if it never
produced a result.
- Commit successful assignment replacement and its completed run status
atomically. Publish success through the status API and SSE only after the
transaction commits. A failed commit must not leave a successful status with
missing assignments, or replaced assignments with an outdated running status.
- On startup, mark calculations left Running by the previous backend instance
as Interrupted before serving their recovered status. Explain that no new
result was applied, retain previously saved assignments and allow a new run.
Do not resume calculations automatically or retain their old run-slot guards.
- Recover the latest status and outcome through S12's existing status endpoint
and initial/reconnection SSE snapshots. Preserve stable run identifiers so
reconnecting clients can deduplicate notifications; reopening the application
must not replay old completion snackbars.
- Verify recovery after both graceful restart and abrupt process termination,
including a calculation running at termination and a completed saved result.
## Database failures and schema changes ## Database failures and schema changes
- Refuse mutations while the database is unavailable and provide a clear failure - Refuse mutations while the database is unavailable and provide a clear failure
@@ -103,8 +131,14 @@ for now. S16 does not promise recovery after loss of the database or its volume.
another: the stale edit receives a clear conflict response, and the user another: the stale edit receives a clear conflict response, and the user
can reload and retry. can reload and retry.
- [ ] Scheduling results based on outdated input revisions are rejected. - [ ] Scheduling results based on outdated input revisions are rejected.
- [ ] Interrupted calculations are recognizably interrupted after restart; - [ ] The latest accepted scheduling run, its stable identifier, timestamps,
previously saved results remain available. progress, outcome and objective summary survive normal restart and crash
and are returned through the status API and SSE snapshots.
- [ ] Successful assignment replacement and completed run status commit
atomically; success is published only after durable commit.
- [ ] Calculations left Running become Interrupted before recovered status is
served; previous assignments remain, no automatic resumption occurs and a
new run can start. Reopening does not replay old completion snackbars.
- [ ] Database unavailability prevents successful mutation acknowledgements; - [ ] Database unavailability prevents successful mutation acknowledgements;
startup connection and migration failures are clearly reported. startup connection and migration failures are clearly reported.
- [ ] An empty database can be initialized through Flyway and populated through - [ ] An empty database can be initialized through Flyway and populated through

File diff suppressed because it is too large Load Diff