test(android): establish workflow runner

This commit is contained in:
QiuSW
2026-07-25 18:47:22 +08:00
parent d013ac8c84
commit d57c4ae2d4
8 changed files with 548 additions and 7 deletions
@@ -0,0 +1,79 @@
package com.roubao.autopilot.workflow
const val MAX_RETRIES_PER_STEP = 3
data class WorkflowStep(
val id: String,
val timeoutMillis: Long,
val maxRetries: Int = 0
) {
init {
require(id.isNotBlank()) { "Workflow step id must not be blank" }
require(timeoutMillis > 0) { "Workflow step timeout must be positive" }
require(maxRetries in 0..MAX_RETRIES_PER_STEP) {
"Workflow step maxRetries must be between 0 and $MAX_RETRIES_PER_STEP"
}
}
}
enum class WorkflowState {
IDLE,
RUNNING,
RETRYING,
SUCCEEDED,
FAILED,
BLOCKED,
STOPPED
}
enum class WorkflowFailureCode {
TIMEOUT,
TRANSIENT_AUTOMATION,
AUTOMATION_EXCEPTION
}
enum class SafetyStopReason {
LOGIN_REQUIRED,
VERIFICATION_REQUIRED,
RISK_CONTROL,
UNKNOWN_PAGE,
PAYMENT_BOUNDARY
}
sealed interface AutomationResult {
data object Success : AutomationResult
data class RetryableFailure(
val code: WorkflowFailureCode
) : AutomationResult
data class FatalFailure(
val code: WorkflowFailureCode
) : AutomationResult
data class Blocked(
val reason: SafetyStopReason
) : AutomationResult
data object Stopped : AutomationResult
}
fun interface AutomationGateway {
suspend fun execute(step: WorkflowStep): AutomationResult
}
data class WorkflowTransition(
val from: WorkflowState,
val to: WorkflowState,
val stepId: String?,
val attempt: Int?
)
data class WorkflowReport(
val state: WorkflowState,
val completedStepIds: List<String>,
val attemptsByStep: Map<String, Int>,
val transitions: List<WorkflowTransition>,
val failureCode: WorkflowFailureCode? = null,
val safetyStopReason: SafetyStopReason? = null
)
@@ -0,0 +1,207 @@
package com.roubao.autopilot.workflow
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeout
class WorkflowRunner(
private val automation: AutomationGateway
) {
private val running = AtomicBoolean(false)
private val mutableState = MutableStateFlow(WorkflowState.IDLE)
@Volatile
private var stopRequested = false
@Volatile
private var currentExecution: Job? = null
val state: StateFlow<WorkflowState> = mutableState.asStateFlow()
suspend fun run(steps: List<WorkflowStep>): WorkflowReport {
require(steps.isNotEmpty()) { "Workflow must contain at least one step" }
check(running.compareAndSet(false, true)) { "WorkflowRunner is already running" }
stopRequested = false
val completedSteps = mutableListOf<String>()
val attempts = linkedMapOf<String, Int>()
val transitions = mutableListOf<WorkflowTransition>()
if (mutableState.value != WorkflowState.IDLE) {
transition(WorkflowState.IDLE, null, null, transitions)
}
try {
for (step in steps) {
var attempt = 0
while (attempt <= step.maxRetries) {
if (stopRequested) {
return terminalReport(
state = WorkflowState.STOPPED,
completedSteps = completedSteps,
attempts = attempts,
transitions = transitions,
step = step,
attempt = attempt.takeIf { it > 0 }
)
}
attempt += 1
attempts[step.id] = attempt
transition(WorkflowState.RUNNING, step.id, attempt, transitions)
when (val result = executeStep(step)) {
AutomationResult.Success -> {
completedSteps += step.id
break
}
is AutomationResult.Blocked -> {
return terminalReport(
state = WorkflowState.BLOCKED,
completedSteps = completedSteps,
attempts = attempts,
transitions = transitions,
step = step,
attempt = attempt,
safetyStopReason = result.reason
)
}
is AutomationResult.FatalFailure -> {
return terminalReport(
state = WorkflowState.FAILED,
completedSteps = completedSteps,
attempts = attempts,
transitions = transitions,
step = step,
attempt = attempt,
failureCode = result.code
)
}
is AutomationResult.RetryableFailure -> {
if (attempt > step.maxRetries) {
return terminalReport(
state = WorkflowState.FAILED,
completedSteps = completedSteps,
attempts = attempts,
transitions = transitions,
step = step,
attempt = attempt,
failureCode = result.code
)
}
transition(WorkflowState.RETRYING, step.id, attempt, transitions)
}
AutomationResult.Stopped -> {
return terminalReport(
state = WorkflowState.STOPPED,
completedSteps = completedSteps,
attempts = attempts,
transitions = transitions,
step = step,
attempt = attempt
)
}
}
}
}
return terminalReport(
state = WorkflowState.SUCCEEDED,
completedSteps = completedSteps,
attempts = attempts,
transitions = transitions,
step = null,
attempt = null
)
} finally {
currentExecution = null
running.set(false)
}
}
fun requestStop() {
stopRequested = true
currentExecution?.cancel(CancellationException("Workflow stop requested"))
}
private suspend fun executeStep(step: WorkflowStep): AutomationResult = supervisorScope {
val execution: Deferred<AutomationResult> = async {
try {
automation.execute(step)
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
AutomationResult.FatalFailure(WorkflowFailureCode.AUTOMATION_EXCEPTION)
}
}
currentExecution = execution
try {
withTimeout(step.timeoutMillis) {
execution.await()
}
} catch (_: TimeoutCancellationException) {
AutomationResult.RetryableFailure(WorkflowFailureCode.TIMEOUT)
} catch (error: CancellationException) {
if (stopRequested) {
AutomationResult.Stopped
} else {
throw error
}
} finally {
execution.cancel()
if (currentExecution === execution) {
currentExecution = null
}
}
}
private fun terminalReport(
state: WorkflowState,
completedSteps: List<String>,
attempts: Map<String, Int>,
transitions: MutableList<WorkflowTransition>,
step: WorkflowStep?,
attempt: Int?,
failureCode: WorkflowFailureCode? = null,
safetyStopReason: SafetyStopReason? = null
): WorkflowReport {
transition(state, step?.id, attempt, transitions)
return WorkflowReport(
state = state,
completedStepIds = completedSteps.toList(),
attemptsByStep = attempts.toMap(),
transitions = transitions.toList(),
failureCode = failureCode,
safetyStopReason = safetyStopReason
)
}
private fun transition(
next: WorkflowState,
stepId: String?,
attempt: Int?,
transitions: MutableList<WorkflowTransition>
) {
val previous = mutableState.value
mutableState.value = next
transitions += WorkflowTransition(
from = previous,
to = next,
stepId = stepId,
attempt = attempt
)
}
}