|
| 1 | +package org.appdevforall.cotg.quickbuild.domain.session |
| 2 | + |
| 3 | +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason |
| 4 | + |
| 5 | +/** |
| 6 | + * What the status surface should show, derived purely from session state rather than set and |
| 7 | + * cleared imperatively. |
| 8 | + * |
| 9 | + * Deriving it makes a stuck banner unrepresentable: every state maps to exactly one status, so |
| 10 | + * every terminal state clears the transient one. A banner cleared only on successful render |
| 11 | + * would leave "Compiling..." up forever after a compile error or a payload crash. |
| 12 | + */ |
| 13 | +sealed interface QuickBuildStatus { |
| 14 | + /** |
| 15 | + * No session - nothing in progress to narrate. |
| 16 | + * |
| 17 | + * @property lastStartFailed the last session start failed |
| 18 | + * ([QuickBuildSessionState.Idle.lastStartFailed]), so the bolt keeps the error tone until |
| 19 | + * the next tap or save; carried here because a failed start rests in Hidden and the tone |
| 20 | + * is derived from status alone. |
| 21 | + */ |
| 22 | + data class Hidden( |
| 23 | + val lastStartFailed: Boolean = false, |
| 24 | + ) : QuickBuildStatus |
| 25 | + |
| 26 | + /** |
| 27 | + * Proxy app build, install and daemon spawn in progress. |
| 28 | + * |
| 29 | + * @property rebaselineReason what invalidated the old baseline, or null on a session's first |
| 30 | + * provision; it has to travel in the status because this conflating |
| 31 | + * [kotlinx.coroutines.flow.StateFlow] lets a surface miss the [NeedsFullBuild] that preceded a |
| 32 | + * rebaseline and then call it "the initial full build". |
| 33 | + */ |
| 34 | + data class Provisioning( |
| 35 | + val rebaselineReason: InvalidationReason? = null, |
| 36 | + ) : QuickBuildStatus |
| 37 | + |
| 38 | + /** |
| 39 | + * A build is running; the proxy app still runs [runningGeneration]. |
| 40 | + * |
| 41 | + * @property runningGeneration the generation live in the proxy app right now, one behind the |
| 42 | + * build in flight. |
| 43 | + */ |
| 44 | + data class Building( |
| 45 | + val runningGeneration: Long, |
| 46 | + ) : QuickBuildStatus |
| 47 | + |
| 48 | + /** |
| 49 | + * The proxy app is running the latest edit. |
| 50 | + * |
| 51 | + * @property generation the generation the proxy app runs, which is also the latest built. |
| 52 | + * @property buildDurationMillis how long the landed save-to-live loop took, in milliseconds - |
| 53 | + * the whole wait, not the build alone; null when no build landed in this session yet, and |
| 54 | + * the surface then shows no timing. |
| 55 | + * @property restarted the deploy relaunched the proxy-app process (service/provider/Application |
| 56 | + * code changed), so the surface phrases it as a restart rather than a plain reload. |
| 57 | + */ |
| 58 | + data class UpToDate( |
| 59 | + val generation: Long, |
| 60 | + val buildDurationMillis: Long?, |
| 61 | + val restarted: Boolean = false, |
| 62 | + ) : QuickBuildStatus |
| 63 | + |
| 64 | + /** |
| 65 | + * The edit did not land; the proxy app still runs [runningGeneration]. |
| 66 | + * |
| 67 | + * @property runningGeneration the generation still live in the proxy app - a failure never |
| 68 | + * moves it. |
| 69 | + * @property failure what went wrong: a compile error, a failed deploy, or a crash of the |
| 70 | + * running generation. |
| 71 | + */ |
| 72 | + data class Failed( |
| 73 | + val runningGeneration: Long, |
| 74 | + val failure: SessionFailure, |
| 75 | + ) : QuickBuildStatus |
| 76 | + |
| 77 | + /** |
| 78 | + * The baseline is stale; only a full Gradle build can move the proxy app forward. |
| 79 | + * |
| 80 | + * @property reason what the live reload path could not absorb, which the surface names to the |
| 81 | + * user. |
| 82 | + * @property runningGeneration the generation still live in the proxy app until the rebuild |
| 83 | + * lands. |
| 84 | + * @property awaitingRetry a rebaseline already ran and parked (build failed or install not |
| 85 | + * confirmed), so the surface must read as a failure the user resolves rather than ordinary |
| 86 | + * upcoming work; see [QuickBuildSessionState.Invalidated.awaitingRetry]. |
| 87 | + */ |
| 88 | + data class NeedsFullBuild( |
| 89 | + val reason: InvalidationReason, |
| 90 | + val runningGeneration: Long, |
| 91 | + val awaitingRetry: Boolean = false, |
| 92 | + ) : QuickBuildStatus |
| 93 | + |
| 94 | + /** |
| 95 | + * The compile daemon died and is being respawned. |
| 96 | + * |
| 97 | + * @property runningGeneration the generation the proxy app keeps running through the outage - |
| 98 | + * its process is untouched. |
| 99 | + * @property restartFailed the respawn did not stick and nothing is retrying it, so the surface |
| 100 | + * must name the gesture that brings the compiler back rather than claim a restart is in |
| 101 | + * progress; see [QuickBuildSessionState.Degraded.restartFailed]. |
| 102 | + */ |
| 103 | + data class Reconnecting( |
| 104 | + val runningGeneration: Long, |
| 105 | + val restartFailed: Boolean = false, |
| 106 | + ) : QuickBuildStatus |
| 107 | + |
| 108 | + companion object { |
| 109 | + /** |
| 110 | + * Maps a session state to the one status that represents it. |
| 111 | + * |
| 112 | + * @param state the current session state; every state maps, so no caller has to handle a |
| 113 | + * missing status. |
| 114 | + * @return the status to render, [Hidden] when the surface should show nothing. |
| 115 | + */ |
| 116 | + fun from(state: QuickBuildSessionState): QuickBuildStatus = |
| 117 | + when (state) { |
| 118 | + is QuickBuildSessionState.Idle -> { |
| 119 | + Hidden(state.lastStartFailed) |
| 120 | + } |
| 121 | + |
| 122 | + // A warm-up the user never asked for stays invisible - but it must not clear a |
| 123 | + // failed-start tone on its way through, so the flag rides along. |
| 124 | + is QuickBuildSessionState.Prebuilding -> { |
| 125 | + // A warm build has no baseline to replace, so a tap that queues on one is |
| 126 | + // always a session's first provision. |
| 127 | + if (state.tapQueued) Provisioning() else Hidden(state.lastStartFailed) |
| 128 | + } |
| 129 | + |
| 130 | + is QuickBuildSessionState.Provisioning -> { |
| 131 | + Provisioning(state.rebaselineReason) |
| 132 | + } |
| 133 | + |
| 134 | + is QuickBuildSessionState.Ready -> { |
| 135 | + state.lastFailure?.let { Failed(state.generation, it) } |
| 136 | + ?: UpToDate(state.generation, buildDurationMillis = null) |
| 137 | + } |
| 138 | + |
| 139 | + is QuickBuildSessionState.Building -> { |
| 140 | + when { |
| 141 | + // A real build: the proxy app is one generation behind, say so. |
| 142 | + !state.warmingCompiler -> Building(state.deployedGeneration) |
| 143 | + |
| 144 | + // A crash of the running generation surfaces immediately, exactly as it |
| 145 | + // would outside the warm-compile window. |
| 146 | + state.pendingCrash != null -> Failed(state.deployedGeneration, state.pendingCrash) |
| 147 | + |
| 148 | + // The warm compile recompiles what already runs and deploys nothing, |
| 149 | + // so the app is genuinely up to date for its whole window. |
| 150 | + else -> UpToDate(state.deployedGeneration, buildDurationMillis = null) |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + is QuickBuildSessionState.Deployed -> { |
| 155 | + UpToDate(state.generation, state.buildDurationMillis, state.restarted) |
| 156 | + } |
| 157 | + |
| 158 | + is QuickBuildSessionState.Invalidated -> { |
| 159 | + NeedsFullBuild(state.reason, state.deployedGeneration, state.awaitingRetry) |
| 160 | + } |
| 161 | + |
| 162 | + is QuickBuildSessionState.Degraded -> { |
| 163 | + Reconnecting(state.deployedGeneration, state.restartFailed) |
| 164 | + } |
| 165 | + } |
| 166 | + } |
| 167 | +} |
0 commit comments