From d57c4ae2d42c6e48a2d0ad0e9f295cd354dc911a Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 25 Jul 2026 18:47:22 +0800 Subject: [PATCH] test(android): establish workflow runner --- android-buyer/app/build.gradle.kts | 1 + .../autopilot/workflow/WorkflowModels.kt | 79 +++++++ .../autopilot/workflow/WorkflowRunner.kt | 207 ++++++++++++++++++ .../autopilot/workflow/WorkflowRunnerTest.kt | 162 ++++++++++++++ docs/03-tech-stack.md | 2 +- docs/current-state.md | 15 +- docs/tasks/T-003.md | 81 +++++++ progress.md | 8 + 8 files changed, 548 insertions(+), 7 deletions(-) create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowRunner.kt create mode 100644 android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt create mode 100644 docs/tasks/T-003.md diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index 3bc4548..089a197 100644 --- a/android-buyer/app/build.gradle.kts +++ b/android-buyer/app/build.gradle.kts @@ -87,6 +87,7 @@ dependencies { // Unit tests testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") // Debug debugImplementation("androidx.compose.ui:ui-tooling") diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt new file mode 100644 index 0000000..1684e6f --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowModels.kt @@ -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, + val attemptsByStep: Map, + val transitions: List, + val failureCode: WorkflowFailureCode? = null, + val safetyStopReason: SafetyStopReason? = null +) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowRunner.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowRunner.kt new file mode 100644 index 0000000..56e75a6 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/WorkflowRunner.kt @@ -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 = mutableState.asStateFlow() + + suspend fun run(steps: List): 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() + val attempts = linkedMapOf() + val transitions = mutableListOf() + + 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 = 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, + attempts: Map, + transitions: MutableList, + 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 + ) { + val previous = mutableState.value + mutableState.value = next + transitions += WorkflowTransition( + from = previous, + to = next, + stepId = stepId, + attempt = attempt + ) + } +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt new file mode 100644 index 0000000..01b50c4 --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt @@ -0,0 +1,162 @@ +package com.roubao.autopilot.workflow + +import java.util.ArrayDeque +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class WorkflowRunnerTest { + @Test + fun `multiple steps complete with explicit transitions`() = runTest { + val automation = QueueAutomation( + AutomationResult.Success, + AutomationResult.Success + ) + val runner = WorkflowRunner(automation) + + val report = runner.run( + listOf( + step("open_app"), + step("verify_page") + ) + ) + + assertEquals(WorkflowState.SUCCEEDED, report.state) + assertEquals(listOf("open_app", "verify_page"), report.completedStepIds) + assertEquals(mapOf("open_app" to 1, "verify_page" to 1), report.attemptsByStep) + assertEquals( + listOf( + WorkflowState.RUNNING, + WorkflowState.RUNNING, + WorkflowState.SUCCEEDED + ), + report.transitions.map { it.to } + ) + } + + @Test + fun `retryable failure retries once then succeeds`() = runTest { + val automation = QueueAutomation( + AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION), + AutomationResult.Success + ) + val runner = WorkflowRunner(automation) + + val report = runner.run(listOf(step("search", maxRetries = 1))) + + assertEquals(WorkflowState.SUCCEEDED, report.state) + assertEquals(2, report.attemptsByStep["search"]) + assertEquals( + listOf( + WorkflowState.RUNNING, + WorkflowState.RETRYING, + WorkflowState.RUNNING, + WorkflowState.SUCCEEDED + ), + report.transitions.map { it.to } + ) + } + + @Test + fun `timeout is bounded and fails after retry budget`() = runTest { + val runner = WorkflowRunner { + delay(10_000) + AutomationResult.Success + } + + val report = runner.run( + listOf(step("wait_for_page", timeoutMillis = 100, maxRetries = 1)) + ) + + assertEquals(WorkflowState.FAILED, report.state) + assertEquals(WorkflowFailureCode.TIMEOUT, report.failureCode) + assertEquals(2, report.attemptsByStep["wait_for_page"]) + assertEquals(200, currentTime) + } + + @Test + fun `all safety reasons block without retry`() = runTest { + SafetyStopReason.entries.forEach { reason -> + var calls = 0 + val runner = WorkflowRunner { + calls += 1 + AutomationResult.Blocked(reason) + } + + val report = runner.run(listOf(step("guard", maxRetries = 3))) + + assertEquals(WorkflowState.BLOCKED, report.state) + assertEquals(reason, report.safetyStopReason) + assertEquals(1, calls) + } + } + + @Test + fun `user stop cancels current child and returns stopped report`() = runTest { + val started = CompletableDeferred() + val runner = WorkflowRunner { + started.complete(Unit) + awaitCancellation() + } + + val runningReport = async { + runner.run(listOf(step("long_action", timeoutMillis = 60_000))) + } + started.await() + runner.requestStop() + + val report = runningReport.await() + assertEquals(WorkflowState.STOPPED, report.state) + assertTrue(coroutineContext.isActive) + } + + @Test + fun `concurrent run is rejected`() = runTest { + val started = CompletableDeferred() + val runner = WorkflowRunner { + started.complete(Unit) + awaitCancellation() + } + val firstRun = async { + runner.run(listOf(step("first", timeoutMillis = 60_000))) + } + started.await() + + try { + runner.run(listOf(step("second"))) + fail("Expected concurrent run to be rejected") + } catch (error: IllegalStateException) { + assertEquals("WorkflowRunner is already running", error.message) + } finally { + runner.requestStop() + assertEquals(WorkflowState.STOPPED, firstRun.await().state) + } + } + + private fun step( + id: String, + timeoutMillis: Long = 1_000, + maxRetries: Int = 0 + ) = WorkflowStep( + id = id, + timeoutMillis = timeoutMillis, + maxRetries = maxRetries + ) + + private class QueueAutomation( + vararg results: AutomationResult + ) : AutomationGateway { + private val remaining = ArrayDeque(results.toList()) + + override suspend fun execute(step: WorkflowStep): AutomationResult = + remaining.removeFirst() + } +} diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index ce9c9f8..40a462e 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -27,7 +27,7 @@ | VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 接口已定,供应商待定 | 模型输出必须符合本项目 JSON Schema。 | | 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 | | 后端测试 | 标准库 `testing` + `httptest` | MVP 已定 | 覆盖状态机、权限、幂等、SQLite 事务和输入校验。 | -| Android 测试 | Gradle `test` + 真实设备 smoke | 基线已验证 | 上游没有 `test/androidTest` 源;T-003 建立 workflow 测试。 | +| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | workflow 基线已实现 | T-003 已覆盖成功、timeout、有限 retry、安全阻塞、用户停止和并发拒绝;真实 UI 动作仍需真机 smoke。 | | 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 | ## Roubao 上游版本基线 diff --git a/docs/current-state.md b/docs/current-state.md index 8d51a69..b8071c2 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,16 +5,18 @@ ## 当前快照 - 日期:2026-07-25 -- 阶段:T-002 测试设备基线和就绪检查完成,准备执行 T-003 -- Git:当前分支为 `main`;T-001 和 T-002 均已纳入 Git 历史 +- 阶段:T-003 Android workflow 测试骨架完成,准备执行 T-004 +- Git:当前分支为 `main`;T-001 至 T-003 均已纳入 Git 历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - 后端:已决定使用 Go 1.23.0 + Gin 1.11.0;Go Blueprint v0.10.11 骨架尚未接入 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 -- 测试:`lintDebug test assembleDebug` 成功;T-002 新增 5 个纯 Kotlin 测试, - Debug/Release 两个变体共执行 10 次且全部通过 +- 测试:`lintDebug test assembleDebug` 成功;T-002/T-003 共 11 个纯 Kotlin + 测试,Debug/Release 两个变体共执行 22 次且全部通过 +- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 + 用户停止和单 runner 并发拒绝;尚未连接真实拼多多动作 - 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多 `8.17.0 (81700)` - 设备就绪:肉包采购无障碍已启用并连接;可观察前台包名;拼多多首页未发现登录、 @@ -36,6 +38,7 @@ | `docs/` | 已有 | Harness Coding 文档 | | `docs/tasks/T-001.md` | DONE | Android 可构建、可安装、可启动基线 | | `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 | +| `docs/tasks/T-003.md` | DONE | 可注入 Fake automation 的受限 workflow runner | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `backend-api/` | 待建 | Go-Gin、管理 Web 和数据目标目录 | @@ -43,9 +46,9 @@ ## 任务摘要 -- 已完成:T-001 Android 基线;T-002 测试设备基线和就绪检查。 +- 已完成:T-001 Android 基线;T-002 设备就绪检查;T-003 workflow 测试骨架。 - 正在进行:无。 -- 下一个可领取任务:T-003 建立 Android workflow 测试骨架。 +- 下一个可领取任务:T-004 导入本机蝦皮订单验证样本。 ## 当前可运行内容 diff --git a/docs/tasks/T-003.md b/docs/tasks/T-003.md new file mode 100644 index 0000000..a3b69cb --- /dev/null +++ b/docs/tasks/T-003.md @@ -0,0 +1,81 @@ +--- +id: T-003 +title: 建立 Android workflow 测试骨架 +phase: 0 +deps: + - T-001 +status: DONE +created: 2026-07-25 +context_ref: d013ac8c84b69ab1bcf5b2cfd5385c93049d5439 +work_branch: main +write_paths: + - android-buyer/app/build.gradle.kts + - android-buyer/app/src/main/java/com/roubao/autopilot/workflow/** + - android-buyer/app/src/test/java/com/roubao/autopilot/workflow/** + - docs/03-tech-stack.md + - docs/current-state.md + - docs/tasks/T-003.md + - progress.md +--- + +## 问题 / 背景 + +现有 Roubao `MobileAgent` 直接组合 VLM、设备控制和 UI 状态,无法在本地 JVM 中稳定 +验证采购步骤的超时、重试和安全停止。T-101 开始操作拼多多前,需要先建立一个小型、 +可注入 Fake automation 的工作流内核,防止自动化逻辑只能靠真机人工回归。 + +## 关联需求与交互 + +- 功能:为 F-004 至 F-007 提供可测试执行骨架。 +- 用户故事:US-004 执行受限采购探针、US-006 安全失败和可恢复。 +- 交互:本任务无新界面。 +- 架构:Android workflow 层和 automation adapter 接口。 + +## 方案 + +1. 定义步骤、自动化结果、状态、失败原因、安全停止原因和运行报告。 +2. `WorkflowRunner` 每次只运行一个工作流;每步使用协程 timeout,重试次数有硬上限。 +3. 用户停止只取消当前 automation 子任务并返回 `STOPPED` 报告,不取消调用方协程。 +4. 登录、验证码、风控、未知页面和支付边界作为不可重试的 `BLOCKED` 终态。 +5. 使用 `kotlinx-coroutines-test` 和 Fake automation 覆盖成功、重试、超时、阻塞、 + 用户停止和并发运行拒绝。 + +## 验收要点 + +- [x] Fake automation 下可以验证多步骤成功状态迁移。 +- [x] 单步 timeout 可确定性触发,且不会形成无界等待。 +- [x] retry 次数有明确上限,耗尽后进入结构化失败。 +- [x] 登录、验证码、风控、未知页面和支付边界不自动重试。 +- [x] 用户停止会取消当前步骤并返回 `STOPPED`,调用方测试协程仍可完成。 +- [x] 同一 Runner 拒绝并发运行。 +- [x] `lintDebug test assembleDebug` 通过。 + +## 边界 + +- 不实现拼多多搜索、点击或页面解析。 +- 不接入 MainActivity 或替换现有 MobileAgent。 +- 不做任务持久化、进程恢复、后台 API 或前台服务。 +- 不允许无限 retry;`maxRetries` 必须为非负且由步骤显式给出。 + +## 执行记录 + +### 2026-07-25:任务开始 + +- 基于 T-002 提交 `d013ac8` 开始。 +- T-002 已提供设备就绪和安全阻塞事实;T-003 只定义如何消费 automation 结果。 + +### 2026-07-25:实现和验证完成 + +- 新增纯 Kotlin `WorkflowStep`、`AutomationGateway`、状态/结果/报告模型和 + `WorkflowRunner`,没有 Android UI、设备或 VLM 依赖。 +- 单步强制正 timeout;`maxRetries` 只能是 0 至 3。timeout 作为结构化可重试失败, + 重试预算耗尽后进入 `FAILED`。 +- 登录、验证码、风控、未知页面和支付边界统一返回 `BLOCKED`,即使步骤配置了重试 + 也只调用 automation 一次。 +- `requestStop()` 只取消当前 automation 子任务;runner 捕获该取消并返回 `STOPPED`, + 不取消调用方协程。原子运行标记拒绝同一 runner 并发执行。 +- 增加 `kotlinx-coroutines-test 1.7.3`,用虚拟时间验证两次 100ms timeout 在 + 200ms 后失败,不产生真实等待。 +- 新增 6 个 workflow 测试;连同 T-002 的 5 个测试,在 Debug/Release 两个变体 + 共执行 22 次,0 failure、0 error、0 skipped。 +- `.\gradlew.bat lintDebug test assembleDebug --no-daemon`:成功。 diff --git a/progress.md b/progress.md index 9bd12f7..25800f6 100644 --- a/progress.md +++ b/progress.md @@ -53,3 +53,11 @@ 登录/验证码/风控分类,并新增设备检查首屏。 - 影响:OnePlus PKG110 + 拼多多 8.17.0 已形成可复现基线;T-101 可以在明确安全 门禁下实现关键词搜索,不再依赖 Shizuku 状态猜测设备是否就绪。 + +## 2026-07-25 Android workflow 测试骨架 + +- 类型:阶段完成 +- 内容:完成 T-003;建立可注入 Fake automation 的纯 Kotlin runner,覆盖 timeout、 + 有限 retry、安全阻塞、用户停止和并发拒绝。 +- 影响:T-101 至 T-104 可以把真实拼多多动作接到稳定状态机上,自动化异常不再依赖 + 真机人工判断。