Skip to content

Commit e3a72ab

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 08/12 core-orchestration — Core slice 4: the session state machine tying the slices together; every transition narrated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent 225d08f commit e3a72ab

28 files changed

Lines changed: 14809 additions & 0 deletions

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt‎

Lines changed: 543 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package org.appdevforall.cotg.quickbuild.domain.session
2+
3+
/**
4+
* Colorblind-safe presentation tone for the Quick Build toolbar icon.
5+
*
6+
* Status is never carried by color alone: each tone maps to a distinct icon shape as well as a
7+
* distinct color. The app module owns that drawable/color mapping because it needs a Context;
8+
* this type is the JVM-testable half.
9+
*
10+
* Only [ERROR] is colored as a failure - a tone the user cannot act on, or that resolves by itself
11+
* (a full rebuild during ordinary editing, a daemon respawn), must not read as one.
12+
*/
13+
enum class QuickBuildTone {
14+
/** Ready to build - no session, or a session sitting on a successful build. */
15+
READY,
16+
17+
/** A build is running (provisioning or an active quick build). Tapping stops it. */
18+
BUILDING,
19+
20+
/** The next build cannot take the fast path and will be a full one. Not a failure. */
21+
SLOW,
22+
23+
/** The compile daemon is being respawned. Transient, resolves itself, nothing to do. */
24+
RECONNECTING,
25+
26+
/** A failure the user has to deal with. */
27+
ERROR,
28+
}
29+
30+
/**
31+
* Derives the toolbar tone from the status the session surface already exposes.
32+
*
33+
* @receiver the status currently rendered, so tone and status can never disagree.
34+
* @return the tone for that status; [QuickBuildTone.READY] also covers a plain
35+
* [QuickBuildStatus.Hidden], where the icon is present but no session is running.
36+
*/
37+
fun QuickBuildStatus.toTone(): QuickBuildTone =
38+
when (this) {
39+
// A failed START is a failure the user has to deal with - only a tap retries it - so
40+
// it must not settle back to the green bolt the moment the failure flash fades.
41+
is QuickBuildStatus.Hidden -> {
42+
if (lastStartFailed) QuickBuildTone.ERROR else QuickBuildTone.READY
43+
}
44+
45+
is QuickBuildStatus.UpToDate -> {
46+
QuickBuildTone.READY
47+
}
48+
49+
is QuickBuildStatus.Provisioning,
50+
is QuickBuildStatus.Building,
51+
-> {
52+
QuickBuildTone.BUILDING
53+
}
54+
55+
// A rebaseline that failed and parked is not ordinary upcoming work: nothing moves
56+
// until the user acts, which is exactly what ERROR means here.
57+
is QuickBuildStatus.NeedsFullBuild -> {
58+
if (awaitingRetry) QuickBuildTone.ERROR else QuickBuildTone.SLOW
59+
}
60+
61+
// A respawn that failed is not invisible work resolving itself: the compiler is down
62+
// until the user taps, which is exactly what ERROR means here.
63+
is QuickBuildStatus.Reconnecting -> {
64+
if (restartFailed) QuickBuildTone.ERROR else QuickBuildTone.RECONNECTING
65+
}
66+
67+
is QuickBuildStatus.Failed -> {
68+
QuickBuildTone.ERROR
69+
}
70+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# `domain/session/` - the session state machine
2+
3+
Pure-JVM state machine for a quick-build session: its states, the events that drive them, the effects the shell must run, and what the user is told. No Android. `SessionReducer.reduce` is total - an unhandled (state, event) pair keeps the state and emits no effects, so a late or duplicate event can never corrupt the session. `QuickBuildStatus` and `QuickBuildTone` derive purely from state, so a stuck banner or a wrong icon color is unrepresentable.
4+
5+
| File | Purpose |
6+
| --- | --- |
7+
| [`SessionReducer.kt`](SessionReducer.kt) | The total transition function: maps (state, event) to next state plus ordered effects. |
8+
| [`QuickBuildSessionState.kt`](QuickBuildSessionState.kt) | The state sealed type plus `SessionFailure`, `SessionEvent`, `SessionEffect`, and `SessionTransition`. |
9+
| [`QuickBuildStatus.kt`](QuickBuildStatus.kt) | The status surface derived from state via `from(state)`. |
10+
| [`QuickBuildTone.kt`](QuickBuildTone.kt) | The colorblind-safe toolbar tone derived from status via `toTone()`. |
11+
| [`QuickBuildNotice.kt`](QuickBuildNotice.kt) | Enum of host-shown notices (named, not written, since this module has no `R`), each carrying its own tone. |
12+
| [`QuickBuildMessage.kt`](QuickBuildMessage.kt) | Sealed type of named failure messages the host maps to string resources; `Literal` passes final text through. |
13+
14+
## State machine
15+
16+
This is the authoritative rendering: every transition with a guard, drawn in full. The copies in [quickbuild/README.md](../../../../../../../../../../README.md) and [docs/pipeline.md](../../../../../../../../../../docs/pipeline.md) are deliberately simplified for orientation.
17+
18+
Arrows are labeled with the `SessionEvent` that drives them; parentheticals note the guard or a key effect. Self-loops that only run an effect (a tap that triggers a live reload, a retry that kicks off a rebuild) are shown; pure no-ops are not.
19+
20+
```mermaid
21+
stateDiagram-v2
22+
[*] --> Idle
23+
24+
Idle --> Provisioning: QuickBuildTapped
25+
Idle --> Prebuilding: PrebuildRequested
26+
27+
Prebuilding --> Prebuilding: QuickBuildTapped (queue the tap)
28+
Prebuilding --> Provisioning: PrebuildFinished (tap queued)
29+
Prebuilding --> Idle: PrebuildFinished (no tap)
30+
Prebuilding --> Idle: CancelRequested (tap queued)
31+
32+
Provisioning --> Ready: ProvisioningSucceeded
33+
Provisioning --> Idle: ProvisioningFailed
34+
Provisioning --> Idle: CancelRequested
35+
Provisioning --> Invalidated: ProxyAppRebuildInstallNotConfirmed
36+
Provisioning --> Invalidated: ProxyAppRebuildDeferred
37+
38+
Ready --> Ready: QuickBuildTapped (TriggerLiveReload)
39+
Ready --> Building: BuildStarted
40+
Ready --> Building: WarmCompileStarted
41+
Ready --> Invalidated: InvalidationDetected
42+
Ready --> Degraded: DaemonDied
43+
Ready --> Ready: ProxyAppCrashed (record failure)
44+
Ready --> Ready: ExternalBuildCompleted (RefreshBaseline)
45+
46+
Building --> Deployed: BuildSucceeded
47+
Building --> Ready: BuildFailed
48+
Building --> Ready: CancelRequested (not warming)
49+
Building --> Ready: WarmCompileFinished
50+
Building --> Invalidated: InvalidationDetected
51+
Building --> Degraded: DaemonDied
52+
53+
Deployed --> Deployed: QuickBuildTapped (TriggerLiveReload)
54+
Deployed --> Building: BuildStarted
55+
Deployed --> Building: WarmCompileStarted
56+
Deployed --> Invalidated: InvalidationDetected
57+
Deployed --> Degraded: DaemonDied
58+
Deployed --> Ready: ProxyAppCrashed (record failure)
59+
Deployed --> Deployed: ExternalBuildCompleted (RefreshBaseline)
60+
61+
Invalidated --> Provisioning: ProxyAppRebuildStarted
62+
Invalidated --> Invalidated: QuickBuildTapped / HostForegrounded (RunProxyAppRebuild)
63+
64+
Degraded --> Ready: DaemonRespawned
65+
Degraded --> Invalidated: InvalidationDetected
66+
Degraded --> Degraded: ExternalBuildCompleted (RefreshBaseline)
67+
68+
note right of Idle
69+
SessionRestartRequested from any
70+
non-Idle state -> Idle (TeardownSession)
71+
end note
72+
```
73+
74+
The reducer is total: any (state, event) pair not drawn above keeps the current state and emits no effects.

0 commit comments

Comments
 (0)