test(android): establish workflow runner
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user