关于 PRD to Flutter APK
这是一个将 JSON 产品需求文档转换为已编译 Flutter 应用的服务,面向需要自动生成应用源代码或 APK 的开发者。它使用多 Agent 流程和真实 Flutter 工具链进行质量检查;可免费生成并查看源代码,APK 通过 Apify Store 获取。
主要功能
- 01从 JSON PRD 生成 Flutter 项目
- 02多 Agent 循环生成与改进
- 03通过真实 Flutter 工具链检查
- 04异步构建并查询任务状态
- 05生成并提供 APK
适用场景
- 01将产品需求快速转为 Flutter 项目
- 02离线运行生成流程
- 03用 Flutter 分析器和测试检查构建
- 04提交构建任务并获取 APK
配置
远程服务无需本地安装。选择你使用的客户端,复制配置或使用一键安装。
https://37-27-249-33.sslip.io/mcpclaude mcp add --transport http carlosge492-prd-to-flutter-apk https://37-27-249-33.sslip.io/mcp{"mcpServers":{"carlosge492-prd-to-flutter-apk":{"type":"http","url":"https://37-27-249-33.sslip.io/mcp"}}}在 Cursor 中打开{"servers":{"carlosge492-prd-to-flutter-apk":{"type":"http","url":"https://37-27-249-33.sslip.io/mcp"}}}在 VS Code 中打开{"mcpServers":{"carlosge492-prd-to-flutter-apk":{"type":"http","url":"https://37-27-249-33.sslip.io/mcp"}}}{"mcpServers":{"carlosge492-prd-to-flutter-apk":{"serverUrl":"https://37-27-249-33.sslip.io/mcp"}}}命令在终端中运行;JSON 粘贴到对应客户端的 MCP 配置后重启。需要授权的服务会在首次连接时提示登录。
工具
详细介绍
应用生成微服务
接收 JSON 产品需求文档,并输出编译完成的 Flutter 应用。它基于 LangGraph 循环构建,包含四个子 Agent 和一个运行真实 Flutter 工具链的 QA 检查关卡。
设计目标不是“生成看似合理的代码”,而是零错误编译: 如果输出未通过 CI,循环就算失败。
运行
从 PRD 生成 Flutter APK,发布于 Apify Store — 免费生成并查看源代码;只需为打包后的 APK 付费。除了 Apify 账号外无需其他账号,也无需安装任何内容。
(本 README 的早期版本曾指向 VM 上可自托管的 x402 服务。该部署已退役——源代码和它验证过的支付门控设计没有改变,只是不再运行于那台机器上。)
付费前先看看它生成的内容
examples/generated-field-notes/ 是未经修改的输出——根据旁边的 45 行 todo_app.prd.json 生成的 23 个文件,已提交到代码库,你可以准确查看构建会生成什么。没有为了展示效果而手动修复任何内容。请从 GENERATED.md 开始。
快速开始
无需凭据,也无需 Flutter SDK——默认配置完全离线:
poetry install
poetry run python src/supervisor.py examples/todo_app.prd.json --clean
这会验证 PRD、运行循环,并将 Flutter 项目写入 generated_apps/current_build/。
若要使用真实工具链而非离线模拟桩对输出进行评分:
poetry run python src/supervisor.py --clean --analyzer dart --flutter-root "C:\flutter" --run-tests
作为服务运行
CLAUDE.md 将其称为 M2M 微服务;这是机器买家会调用的部分。
X402_SHARED_SECRET=... FLUTTER_ROOT="C:\flutter" poetry run uvicorn src.service.app:app --port 8000
| 端点 | |
|---|---|
POST /builds | 提交 PRD;返回 202 + 任务 ID,或返回带有 x402 挑战的 402 |
GET /builds/{id} | 状态、日志、诊断信息 |
GET /builds/{id}/apk | 构建产物 |
GET /healthz | 包含支付是否已配置的信息 |
构建需要几分钟,因此 API 是异步的。提交的 PRD 中的 x402_payment_verified 会被丢弃:PRD 由买家提供,因此信任该字段会让任何人都能声称自己已付款。只有服务端的验证器会设置该字段。
支付采用真实的 x402:买家签署 EIP-3009 TransferWithAuthorization(EIP-712),服务会恢复签名者,检查收款方、金额、链和有效时间窗口,并以原子方式认领 nonce,确保一个签名恰好只能购买一次构建。
X402_TOKEN_CONTRACT=0x036CbD... X402_CHAIN_ID=84532 X402_PAY_TO=0xYourAddress X402_PRICE_ATOMIC=500000 poetry run uvicorn src.service.app:app --port 8000
如果不进行任何配置,服务会拒绝所有支付——它会安全地默认拒绝,而不是回退到开发用共享密钥。/healthz 会报告当前启用的模式。
验证不等于结算。 有效签名证明付款人授权了一笔转账;但无法证明其余额充足,也无法证明转账已在链上完成。设置 X402_FACILITATOR_URL 后,服务会提交授权以在链上执行,并在确认前阻止返回 202——因此只有资金实际转移后才会启动构建。未设置时,/healthz 会报告 settlement: verification-only,此时服务接受的是已签署的支付承诺。
认领 nonce 之前,服务会向协调器的 /verify 端点询问该笔支付是否能够结算。余额不足可以补救——付款人可以充值后重新提交同一签名——若因此消耗其授权,就会造成不必要的损失。
结算会区分三种结果,而非两种。拒绝(“余额不足”)是明确结果,不会重试。超时则是未知状态:协调器可能已广播交易但未能响应,因此服务会拒绝构建,同时记录买家可能已被扣款。重试传输故障是安全的——EIP-3009 nonce 在链上只能使用一次,因此重复提交会回滚,而不会重复扣款。
可用的测试网配置
已在 Base Sepolia 上端到端验证:一笔真实交易,一个真实的 APK:
export X402_TOKEN_CONTRACT=0x036CbD53842c5426634e7929541eC2318f3dCF7e # USDC
export X402_CHAIN_ID=84532
export X402_NETWORK=base-sepolia
export X402_PRICE_ATOMIC=3000000 # 3.00 USDC
export X402_PAY_TO=0xYourReceivingAddress
export X402_FACILITATOR_URL=https://facilitator.payai.network # public, no auth
export FLUTTER_ROOT="C:\flutter"
poetry run uvicorn src.service.app:app --port 8000
付款方只需要 USDC——不需要 ETH。facilitator 提交交易并支付 Gas,这正是 EIP-3009 的意义所在;如果钱包被要求提供测试网 ETH,就说明某处配置有误。水龙头:https://faucet.circle.com。
scripts/check_x402_funding.py 会向 facilitator 的 /verify 发起请求,报告付款方是否有足够资金,而不是引入可能与门禁实际使用的来源不一致的第二个事实来源。
运行多个 worker
设置 REDIS_URL 后,nonce 存储和任务存储都会切换到 Redis;/healthz 会报告 multi_process_safe。nonce 消费使用 SET NX EX,通过一次原子往返操作,确保检查时与使用时之间的保护在多个 worker 之间生效,而不只是局限于单个解释器。Redis 故障时会拒绝付款,而不是允许重放。
在重启后继续运行
资金会在构建开始前上链,因此如果部署导致构建任务丢失,就等于收了款却什么也没做。因此,执行过程本身是持久的,而不只是任务状态:POST /builds 会结算付款、将 PRD 写入任务记录,并把任务 ID 推送到队列。worker 从队列中拉取任务。
BUILD_WORKER_EMBEDDED=0 poetry run uvicorn src.service.app:app --port 8000 # accepts
poetry run python -m src.service.worker # builds
默认情况下,API 进程除了接收请求,也会执行构建,因此单容器部署无需设置这两个标志;当请求处理和构建需要分别扩容时,再设置 BUILD_WORKER_EMBEDDED=0。
worker 会为正在构建的任务持有租约,并在工作期间续约。终止 worker 后,租约会到期,另一个 worker 会将任务放回队列,并根据存储的 PRD 重新构建——这就是为什么要存储 PRD,而不是像执行逻辑仍在接收进程中时那样,将其捕获在闭包中。scripts/verify_durable_execution.py 会通过真实进程和真实的 SIGKILL,准确演示这一过程。
这里有两点是刻意不作保证的。租约只是限制重复工作,而不能阻止它:如果 worker 与 Redis 的连接中断时间超过租约时长,它就无法与已死亡的 worker 区分,因此即使它仍在运行,任务也可能被交给其他 worker。renew 返回 False 时,停滞的 worker 会知道应丢弃结果,但重叠执行确实会发生;它消耗的是计算资源而非资金——付款在任务入队前已结算一次。重试次数也有上限(BUILD_MAX_ATTEMPTS,默认值为 3):否则,一个总会导致 worker 崩溃的构建任务会被重新入队并交给下一个 worker,最终逐个拖垮整个 worker 集群。
| 变量 | 默认值 | 用途 |
|---|---|---|
BUILDS_RATE_LIMIT | 20 | 每个地址在每个时间窗口内可调用 POST /builds 的次数;0 表示禁用 |
BUILDS_RATE_WINDOW_SECONDS | 60 | 该限流规则适用的时间窗口 |
BUILD_WORKER_EMBEDDED | 1 | API 进程是否也执行构建 |
BUILD_LEASE_SECONDS | 300 | 无响应的 worker 可保留其任务的时长 |
BUILD_HEARTBEAT_SECONDS | 30 | 正在工作的 worker 汇报状态的频率 |
BUILD_MAX_ATTEMPTS | 3 | 放弃构建前的最大尝试次数 |
/healthz 会报告 durable_execution;只有队列和任务存储都由多个进程共享时,该值才为 true——如果队列使用 Redis,而任务存储仍在内存中,构建恢复所依赖的记录就会丢失。
为什么接受请求的端点会受到限流
x402 门禁会阻止任何人免费获取构建,因此这不是为了防盗。问题在于拒绝请求的成本:携带付款标头的 POST /builds 会让服务调用支付协调器的 /verify,然后再调用 /settle;这两个网络往返各有 60 秒超时,却都发生在同步端点上。几百个并发的无效授权请求就足以耗尽线程池,让服务停止响应所有人——包括已经付款的买家。因此,限流只针对这个端点;轮询正在运行的构建和下载 APK 的成本都很低,而且这类请求本来就会频繁出现,限制它们反而会惩罚正常行为。
识别调用方这一步很容易悄无声息地出错。 TLS 代理之后的所有请求,在 compose 网络中都来自 Caddy 的地址,因此如果按套接字对端进行限流,全球所有买家都会被归入同一个桶——限流器看似配置好了,实际却会阻止所有人或不阻止任何人。客户端身份应取 X-Forwarded-For 中最右侧的条目,因为 Caddy 会把它实际看到的对端地址追加到调用方发送的内容后面。更常见的做法是取最左侧的条目,但这样调用方就能自行提供标头,每次请求都伪造一个新身份;有一项测试正是专门确保这种情况会失败。
部署
一个容器负责接受付款并构建 APK,Redis 则让这两项功能都具备持久性。它需要一台带磁盘的 VM:镜像中包含 Flutter、Android SDK 和 JDK,体积达 7.71 GB,因此无法部署到无服务器平台,也不适合大多数 PaaS 免费套餐。
以前,构建一个 144 MB 的 APK 会产生 2.0 GB 的 Gradle 输出,而且这些文件不会被清理——任务记录会在七天后从 Redis 中过期删除,但这删错了对象:记录只有几 KB,而它所指向的目录却有几个 GB。现在,服务会保留构建产物,并在构建完成时立即删除整个目录,而此时工作进程仍持有租约。在部署环境中的测量结果:上一个未清理的构建占用 2.0 GB 磁盘空间,第一个清理过的构建占用 144 MB,而且仍可下载。在一台 150 GB 的服务器上,这意味着大约可以销售 58 次,而不是约 790 次。
cp .env.deploy.example .env.deploy # fill in X402_PAY_TO and ANTHROPIC_API_KEY
docker compose --env-file .env.deploy up -d --build
poetry run python scripts/verify_deployment.py http://127.0.0.1:8000
docs/DEPLOY.md 是部署手册:说明机器应如何配置以及原因、Docker 发布端口给 ufw 带来的防火墙陷阱,以及正确的操作顺序——先构建,通过 SSH 隧道验证,购买一次真实构建,然后才将服务放到 TLS 保护下的公网地址上。
Dockerfile 中的每个工具链版本都固定为通过上述表格验证的版本——Flutter 3.44.8、Android SDK 36、build-tools 36.0.0、JDK 17。任何版本浮动都会让构建环境变成不断变化的目标,而这是承诺“可以编译”的服务绝不能承受的。
tests/test_deployment_config.py 会在版本发生漂移、compose 中的 ${VAR} 没有对应的配置值,或 builds 卷消失时测试失败。
最后那条命令的重点是部署检查。 容器可以成功启动并响应 HTTP,但仍然无法销售:收了款却从未结算、重启后丢失已付款的构建,或因令牌变量拼写错误而回退到开发环境的共享密钥。/healthz 的设计目的就是用一行信息揭示这些问题,而 verify_deployment.py 一旦发现问题就会拒绝部署。它本身也针对四种故意配置错误的部署进行了验证——没有 Redis、没有支付协调器、没有令牌配置、链错误、没有限流——总共五种情况,并且都能在消息中指出修复方法。--pay 还会使用测试网密钥购买一次真实构建;这是唯一能够证明部署确实可以完成其收费所提供功能的检查。
在将流量导向面向买家的界面之前,有两件事值得了解。服务默认使用 Claude 生成器,因此未设置 ANTHROPIC_API_KEY 的部署会收款,然后构建失败;SUPERVISOR_GENERATOR=template 是免费的确定性路径,也是合理的首次部署选择。而 SUPERVISOR_BUILD_MODE 决定买家获得的是可安装的调试 APK,还是未签名的发布版 APK——下文会说明服务为何不会签名。
镜像现已成功构建并售出。 一台运行 Ubuntu 26.04、配置为 4 vCPU / 8 GB 的 Hetzner 主机首次尝试便成功完成构建;一笔签名的 EIP-3009 授权在 Base Sepolia 上完成结算
(0x0637c94b…),买家通过该服务端到端购得了一个 151 MB 的 APK。
它并非首次尝试就成功,而它失败的方式才是重点。镜像的每一层都构建成功,服务正常启动,/healthz 报告的运行状态正确,部署检查也通过了——但随后,首次付费构建失败了:
PermissionError: [Errno 13] Permission denied: '/opt/flutter/bin/flutter.bat'
Flutter SDK 在所有平台上都会提供 flutter 和 flutter.bat。有四处调用点各自通过选择第一个 exists() 的候选项来查找工具,并且都优先选择 Windows 的命名方式——在这里这项选择是正确的,但在实际要运行它的机器上却是无法运行的批处理文件。299 个测试、一次评估扫描、APK 打包和整张验证表都已在 Windows 上通过,而这个 bug 在 Windows 上不会出现。只有买家付费构建时才发现了它,这恰恰是付费检查要在买家仍是我们自己时触发的失败模式。现在,src/ports/toolchain.py 会统一处理这个问题,并将 windows= 作为参数,因此无论在哪个平台,都可以通过测试覆盖这两个分支。
发布版构建,以及服务为何不会为其签名
flutter create 会创建一个发布版构建类型,并在 // TODO: Add your own signing config 注释下使用 调试 密钥签名。如果不做修改,flutter build apk --release 会成功,生成一个看起来正常的 app-release.apk,可以安装到设备上,却无法发布——Play 会拒绝调试密钥。买家已经付费后,直到上传时才会发现这一点。
因此,--build-mode release 会移除该配置,并生成一个未签名的 APK。
poetry run python src/supervisor.py <prd.json> --execute \
--build-mode release --flutter-root "C:\flutter" --sdk-root "C:\Android"
服务不会签名,也不持有任何密钥。发布密钥决定了谁可以向应用的已安装用户群推送更新;持有买家的密钥,就意味着掌握了这里生成的每个应用的这种权力,而一次安全漏洞就会让所有应用一并受到影响。买家使用这个服务永远不会接触到的密钥进行签名:
zipalign -p -f 4 app-release-unsigned.apk app-release.apk
apksigner sign --ks my-release-key.jks --ks-key-alias mykey app-release.apk
apksigner verify --print-certs app-release.apk
有两点值得了解。Gradle 编辑是尽力而为的处理,并非保证——对 Flutter 版本迭代中可能变化的模板进行模式匹配,不足以作为安全保障——因此,流水线随后会检查实际生成的 APK;如果无法证明发布版构建未签名,就会失败,而不是将它交给买家。该检查需要 apksigner,因此发布版构建必须指定 --sdk-root 或 ANDROID_SDK_ROOT;如果没有,状态就是 unknown,而未知状态会被拒绝,因为往令人安心的方向猜测,正是导致买家拿到被调试密钥签名却标记为发布版构建的 APK 的原因。
命令
| 内容 | 命令 |
|---|---|
| 构建一个应用 | poetry run python src/supervisor.py <prd.json> |
| 评测测试(离线,约 1 秒) | poetry run python evals/run.py |
| 评测测试(真实分析) | poetry run python evals/run.py --analyzer dart --flutter-root "C:\flutter" |
| 评测测试(+ widget 测试) | 添加 --run-tests |
| 单元测试 | poetry run pytest |
| 构建 worker(从队列中拉取任务) | poetry run python -m src.service.worker |
| 验证构建在 worker 被终止后仍能继续 | poetry run python scripts/verify_durable_execution.py |
| 验证发布 APK 未签名且可签名 | poetry run python scripts/verify_release_signing.py |
| 验证生成的应用确实能与 Firestore 通信 | poetry run python scripts/verify_firestore_roundtrip.py |
| 验证用户可以通过点击进入每个屏幕 | poetry run python scripts/verify_navigation_flow.py |
| 检查部署是否已配置为出售构建 | poetry run python scripts/verify_deployment.py <url> |
| 检查 x402 付款方是否已充值 | poetry run python scripts/check_x402_funding.py |
实用标志:--generator {template,claude}、--analyzer {stub,dart}、
--max-repairs N、--build-dir PATH、--clean、--execute、
--build-mode {debug,release}、--sdk-root PATH。
Poetry 可能不在 PATH 中;无论如何,python -m poetry ... 都能正常工作。Flutter
SDK 特意未加入 PATH——请传入 --flutter-root 或设置 $FLUTTER_ROOT。
工作原理
plan ──> genui ──> logic ──> qa ──┬──> package ──> END
^ ^ │
└──────────┴── repair ──┘ (bounded by --max-repairs)
每个子 Agent 负责输出中的一个严格划分的部分,不得在其负责范围之外写入—— 此限制由代码强制执行,而非仅写在文档中:
| 路径 | 所属方 |
|---|---|
DESIGN.md、pubspec.yaml、analysis_options.yaml | 规划 |
lib/ui/** | GenUI |
lib/providers/**、lib/main.dart | 逻辑 |
test/** | QA(根据生成的源代码推导) |
QA 诊断会路由给实际能够修复问题的 Agent。如果某一轮没有任何改动,路由器会转而升级给另一个 Agent,而不是耗尽整个修复预算,反复运行已经失败的 Agent。
三种生成器
两者都实现相同的 CodeGenerator 接口,因此图不会感知它使用的是哪一种。
template(默认)——确定性的 Python 模板。无需网络和凭据。这是已验证的路径。fixture——一个测试替身,可生成正确的 Flutter 代码,但不使用TemplateGenerator的任何命名约定。证明评分框架并非只在给自己的输出打分。参见evals/fixture_generator.py。claude——调用真正的 Claude,并使用结构化输出。在 Opus 5 上通过评测测试验证,结果为 11/11。
实际验证的内容
如实说明状态,因为“能编译”和“能运行”是两种不同的说法:
| 属性 | 方式 | 状态 |
|---|---|---|
| PRD 契约 | Pydantic、引用完整性 | ✅ |
| 可编译 / 类型检查通过 | 实际运行 flutter analyze | ✅ 11/11 个 PRD |
| 符合 PRD | src/ports/conformance.py | ✅ |
| 无未接线 / 臆造代码 | 分析器 + 一致性检查 | ✅ |
| 屏幕在运行时确实能够构建 | 生成的组件冒烟测试 | ✅ |
| 修复循环能够如实恢复 | 故障注入 | ✅ |
| x402 门控 | 单元测试 | ✅ |
| EIP-712/EIP-3009 签名验证 | 真实密钥,28 项测试 | ✅ |
| 重放保护 | 原子认领,并发竞态测试 | ✅ |
| Facilitator 线路格式 | 实时 facilitator.payai.network | ✅ 载荷已接受,已恢复付款方 |
| 链上结算 | 实时,Base Sepolia | ✅ 真实交易,约 1.2 秒 |
| 链上拒绝重放 | 绕过存储 | ✅ duplicate_settlement |
| 多进程重放安全 | 2 个 uvicorn 工作进程,共享 Redis | ✅ 跨进程拒绝重放 |
| 持久化构建执行 | 构建中途终止工作进程,跨进程 | ✅ 由另一工作进程重新构建 |
| 重复工作受限,但不排除 | 停滞时租约到期 | ⚠️ 设计如此——见上文 |
| HTTP 服务端到端测试 | 实时服务,PRD → APK 下载 | ✅ |
| 买方无法自行认证付款 | 伪造标记 → 402 | ✅ |
| Claude 生成器——请求格式 | 模拟传输,18 项测试 | ✅ |
| 测试框架可用于非模板代码 | --generator fixture | ✅ 完整流水线 + APK 路径 |
| Claude 生成器(Opus 5) | 完整评估扫描 | ✅ 11/11 |
| APK 打包(模板) | flutter build apk --debug | ✅ applicationId 正确 |
| APK 打包(Opus 输出) | flutter build apk --debug | ✅ 145 MB,受 x402 门控 |
| 生成的应用能够启动 | 在 Chrome 中执行 main() | ✅ 每个应用此前都无法启动 |
| 真实 Firestore 读写 | 模拟器 + Chrome,生成的应用 | ✅ 模板生成器,仅限 Web |
| 生成的日期字段可往返保留 | 同上 | ✅ 此前无法运行;见下文 |
| 每个生成的应用都附带往返测试 | 在 QA 阶段根据 PRD 生成 | ✅ 未在循环中运行——需要模拟器 |
| 该测试生成器并非模板专用 | 探测两个生成器的序列化器配对 | ✅ fromSnapshot/toMap 和 fromDoc/toJson |
| Claude 输出可用于实时 Firestore | Opus 5 应用,模拟器 + Chrome | ✅ 往返测试通过 |
| 针对无人编写的模型代码也能进行发现 | Opus、Haiku、模板、fixture | ✅ 四种序列化器形态 |
| 已声明的导航确实已接线 | 一致性检查 unreachable_screen | ✅ 发现 3 个失效路由 |
| 导航后目标页面能够渲染 | 应用自身的 main() + Navigator,Chrome | ✅ 每个 PRD 均生成 |
| 用户点击交互入口后能够到达目标页面 | 应用自身的按钮,Chrome + 模拟器 | ✅ 模板输出,3 个操作 |
| 点击测试并非装饰 | 隐藏交互入口,其他内容保持不变 | ✅ 恰好是正确的测试失败 |
| 针对模型自身组件选择进行同样测试 | — | ❌ 尚未在 Claude 输出上运行 |
| 填写表单后再点击 | — | ❌ 多步骤流程;见下文 |
| Release 构建未签名,而非使用调试签名 | 实际 --release 构建,apksigner | ✅ 已验证未签名 |
| 买方可以为制品签名 | 使用临时密钥库签名 | ✅ 可通过买方证书验证 |
| Play 上传 | — | ❌ 需要 Play 账号和应用条目 |
| 拒绝配置错误的部署 | 5 个故障服务,真实 HTTP | ✅ 全部 5 个均被发现,并指出修复方法 |
| 接受请求的端点有速率限制 | 限流器 + 端点测试 | ✅ 21 项测试,两种后端 |
| 测试套件可在 Linux 上运行,不仅限于 Windows | GitHub Actions 矩阵 | ⚠️ 已编写,尚未执行——没有远程环境 |
| 可构建部署镜像 | Hetzner VM,Ubuntu 26.04,amd64 | ✅ 7.71 GB,一次构建成功 |
| 买方付款并收到 APK | 实时部署,Base Sepolia | ✅ 真实交易,151 MB APK |
| 工具链可在 Linux 上解析 | 已部署的构建 | ✅ 此前无法运行;见下文 |
| 完成的构建不再占用 2 GB | 实时部署,前后对比 | ✅ 2.0 GB → 144 MB |
| 精简后的构建仍可下载 | 精简后重新获取 | ✅ 200,151 MB |
| 可通过 TLS 在公开地址访问 | Let's Encrypt,TLS-ALPN-01 | ✅ 受信任的证书,无需隧道 |
| 公开互联网中的买方可以购买 | 通过公开 HTTPS 付费运行 | ✅ 已交付 151 MB APK |
关于 Redis 的两行,有一点需要说明,因为这里的“✅”确实有实际含义。开发机器上没有 Redis 守护进程,因此两项都通过 fakeredis 验证——单元测试在进程内运行,跨进程持久性测试则通过 TcpFakeServer 运行,它使用真正的 TCP 套接字并传输真正的 RESP。进程边界、套接字和 SIGKILL 都是真实的;实现 LMOVE 和键过期的服务端则不是。只需设置 REDIS_URL,就能通过真实的 Redis 运行 scripts/verify_durable_execution.py,但目前尚未这样做。 |
这些条目可是费了不少功夫才配得上星号。上面的内容都是静态检查——flutter analyze 证明代码通过类型检查,一致性检查证明它符合 PRD,生成的 Widget 测试证明每个屏幕在覆盖其 Provider 的情况下都能单独构建——但这些都不会运行应用。真正运行后,才发现生成器之前生成的每个应用都有两个 Bug。
应用无法启动。 组合根使用了 await Firebase.initializeApp();,却没有提供任何选项,而且生成器也不会生成 google-services.json。在 Web 上,这会在首帧显示之前触发 firebase_core_web 内部的断言失败;在原生平台上,则没有可回退使用的配置文件。应用成功编译、通过静态分析和 Widget 测试,还打包成了可发布的 APK——却在启动时崩溃。现在,标识符通过 --dart-define 传入;未提供时会传入 null,因此携带原生配置文件的构建仍可正常运行。
日期在读回时会抛出异常。 Firestore 会将 DateTime 存储为 Timestamp,读回时也会返回 Timestamp,因此 data['x'] as DateTime?——虽然 Dart 类型正确——但应用第一次读取自己写入的文档时总会抛出异常。
这两项修复都由测试保护:使用旧输出时测试失败,使用新输出时测试通过。这也是唯一值得信任它们的理由。
现在,这项保护不再依赖手写。QA 阶段会为每个模型生成一个 integration_test/<model>_roundtrip_test.dart,而生成的测试本身也经过同样的验证:使用已修复的映射器时测试通过,使用旧版本时则会因 Timestamp is not a subtype of DateTime? 而失败。
测试会通过应用自己的序列化器写入,再通过应用自己的反序列化器读回,这是唯一不会掉进陷阱的做法。手写文档时必须选择一种线上格式,而两个生成器有意采用了不同做法——TemplateGenerator 将日期存储为 Timestamp,fixture 生成器则存储为 ISO-8601 字符串;两者都正确,因为各自都会读回自己写入的内容。这一对方法也没有任何硬编码:方法名称会从生成的代码中读取,因为 fromSnapshot/toMap 和 fromDoc/toJson 是同一契约的不同写法;如果测试只针对某个生成器的方法名称编写,就会悄无声息地不再测试另一个生成器。
运行测试需要主动选择。它需要模拟器和浏览器;若让分析流程依赖 Node、JDK 21 和 Chrome,就会给每次构建带来很大负担,而大多数构建都无法运行这项检查。
这个 ✅ 也有一项限制:测试是在 Chrome 上运行的,不是在 Android 上——这台机器没有 AVD 或系统映像——这足以验证与平台无关的 Dart 映射器 Bug,却不足以宣称应用能在手机上运行。
现在,Opus 的输出已经通过了这项测试:它生成的模型能在两个方向上正确处理 Timestamp,因此提示词指导确实有效,而不只是写在那里。为此付出了两个 Bug 的代价;两者都出在发现阶段,而且犯的是同一个错误,正是编写此模块要避免的——编写时对当时唯一可用的生成器过拟合。
TemplateGenerator会将每个构造函数参数标记为required。Claude 会将其中大多数参数设为可选,并提供默认值。只匹配required this.x时,找不到任何内容可供断言,因此完全没有生成测试——这种失败看起来和成功一模一样。- 反序列化器不必接收
DocumentSnapshot。Opus 会编写fromMap(String id, Map<String, dynamic> map),让模型不依赖 Firestore 类型;这或许是更好的设计,但也因此无法匹配任何内容。
现在涵盖了四种序列化器形式——fromSnapshot/toMap、fromDoc/toJson、fromFirestore/toFirestore、fromMap/toMap——并覆盖两种调用参数数量。这个仓库反复吸取的教训是:编写检查时若只参考一个生成器,检查中就会编码那个生成器的偏好;要知道检查是否有效,唯一办法就是用别人编写的代码来运行它。
无法抵达的路由
PRD 将导航声明为数据——Action(kind="navigate", target=<screen>)——而且 schema 已经会拒绝指向不存在屏幕的目标。生成器列出了路由表中的所有这些屏幕,却从未推送任何路由。/capture 和 /settings 都存在,但没有任何 widget 能到达它们:这个应用只是一张无人能打开的表单。
所有检查都通过了。每个屏幕都能独立构建,因此冒烟测试全绿。路由表是有效的 Dart 代码,而分析器不会判断某个路由是否真的被导航到。符合性检查确认屏幕确实存在,而它们的确存在。
unreachable_screen 解决了这个问题,并且刻意不限制屏幕的抵达方式——命名路由、用于构建 widget 的 MaterialPageRoute,或路由器的 go() 都算。它不会接受的情况是:目标只出现在声明该目标的路由表中,而这正是那个 bug 的表现形式。
有两点值得记录。第一版检查是空洞的:它会在每个 UI 文件中搜索 CaptureScreen(,结果在目标屏幕自己的文件中找到了它自己的构造函数,于是每个无法抵达的屏幕都看起来可以抵达。直到用实际出错的输出而非 fixture 运行检查,这个问题才被发现。检查生效后,它立刻又找到了两个之前无人查看的实例——auth_flow 和 many_screens——因为修复只覆盖了列表屏幕,而身份验证、设置和详情屏幕都没有可供导航挂载的入口。
unreachable_screen 提供的是静态保证:应用中存在导航到每个已声明目标的代码。它并不说明目标页面能否正常工作,也无法做到这一点——无论 CaptureScreen 是否能成功构建,'/capture': (context) => const CaptureScreen() 都能满足这项检查。
因此,QA 阶段还会生成 integration_test/navigation_test.dart,通过应用自己的 main() 启动应用——使用真实的组合根、真实的 ProviderScope 和真实的 Firebase 初始化——再驱动应用自己的 Navigator 导航到每个已声明目标,并检查目标页面是否显示在屏幕上。从生成的应用中删除某条路由,测试就会报错,提示 Could not find a generator for route RouteSettings("/capture");这也证明它确实在测试。
目标屏幕通过其PRD 标题来识别;无论生成器如何命名类、文件或路由,这个标签始终不变。匹配 <Id>Screen,等于匹配某个生成器的约定。
点击按钮,以及我们如何确认这个测试确实有效
驱动路由器仍然没有让用户参与其中。unreachable_screen 说明源代码中有内容会导航到每个目标;推送测试说明抵达后目标页面会渲染。两者都无法说明,查看源屏幕的人是否有可点击的内容。
第一版检查拒绝继续推进,理由是查找按钮取决于 widget 的选择,而且会导致测试不稳定。不过,三个决定让它变得可靠:
- 可点击对象不只是一个按钮列表。
InkResponse位于TextButton、ElevatedButton、IconButton、FloatingActionButton和ListTile下方;其余情况则由CupertinoButton和手写的GestureDetector覆盖。枚举按钮类只会体现TemplateGenerator的偏好, 还会漏掉最常见的交互方式:生成的列表屏幕会从 FAB 导航,而 FAB 唯一的子项是图标,因此根本没有标签。 - 每次尝试都会从重新推入的源屏幕开始, 将“不断点击,直到发生某些事”从一个有状态的序列变成 N 个独立的单次点击实验。这一点很重要:生成的 FAB 会在导航前写入草稿文档,因此如果不重置,每次尝试面对的屏幕都会与上一次不同。
- 到达目标时,先看路由名称,再看标题。 按钮的标签与目标标题相同,是不错的 UI;如果“到达”意味着“标题出现在屏幕上”,那么测试会在点击任何内容之前就通过——所以只有当标题此前并未显示时,才算到达。
这些都不是证据。scripts/verify_navigation_flow.py 才是:它会生成包含八个屏幕的 many_screens 应用,在 Chrome 中针对 Firestore 模拟器运行导航测试,然后再针对一个变异版本运行一次,该版本将一个交互对象包装在 Offstage(offstage: true, ...) 中。
选择这种变异,是因为它不会影响其他功能。Navigator.pushNamed
调用仍然保留在文件中,因此 unreachable_screen 仍会通过。路由表没有改动,因此 push 测试仍会通过。Widget 仍在树中,因此构建不会有任何变化。它只是永远不会显示在屏幕上——应用随附了一个用户无法打开的屏幕,而所有静态检查仍然全绿:
Failure in method: openToday: a tap on inbox reaches today
Expected: empty
Actual: 'tapped 2 of 2 affordance(s) on /inbox and none of them reached /today'
六个生成的测试用例中,恰好只有一个失败,而且失败得恰到好处。这是我们相信点击测试确实测到了东西的唯一理由。
它仍无法证明多步骤流程能够到达目标。只有在凭据有效时才会导航的登录屏幕,需要先点击再输入一些内容,而 PRD 并未说明要输入什么。这些测试会在此处失败,而不是被悄悄跳过,这个方向是对的——但这意味着点击测试失败表示“请查看这里”,而不自动意味着“应用有问题”。
而且它只在模板输出上运行过。 这个仓库里的每个过拟合 Bug,在别人编写的代码通过检查之前都无法发现——往返发现机制曾因此两次失效。交互对象搜索的编写方式不依赖于它检查的是谁的 Widget,这种说法恰恰是我们过去在这里犯过错的地方。值得接下来投入精力的,是在此脚本中使用 --generator claude。
自动化需要自己的浏览器。 普通 Chrome 会话正在运行时,flutter drive -d chrome 无法获取可控制的 Chrome——启动操作会委托给现有实例,调试端口不会打开,dwds 在大约 25 秒后会因 AppConnectionException 失败。这看起来像是代码故障,实际并非如此。将 CHROME_EXECUTABLE 指向一个版本与 chromedriver 匹配的 Chrome-for-Testing 二进制文件:
npx @puppeteer/browsers install [email protected] --path ~/chrome-for-testing
export CHROME_EXECUTABLE=~/chrome-for-testing/chrome/win64-150.0.7871.124/chrome-win64/chrome.exe
除非通过 --driver-port 指定其他端口,否则 flutter drive 也会要求 WebDriver 使用 4444 端口;如果 chromedriver 使用其他端口,报错信息会提到 4444,而不是实际指定的端口。
339 个单元测试;针对真实分析和 Widget 测试的评估横向检查为 11/11; 根据经过付款验证的 PRD 端到端构建出的调试 APK。
目录结构
src/
supervisor.py CLI entry point
prd/schema.py the PRD contract — strict, validates referential integrity
graph/ LangGraph wiring, shared state, the five nodes
ports/
generator.py CodeGenerator protocol
templates.py deterministic generator (validated)
llm.py Claude generator (Opus 5, validated)
analyzer.py StubAnalyzer (offline) + FlutterAnalyzer (real)
conformance.py does the app match the PRD?
smoke.py generates widget tests from the output
roundtrip.py generates the Firestore round-trip test from the output
navigation.py generates the runtime navigation test: push and tap
runtime.py runs them
ownership.py which agent can fix which diagnostic
payments/x402.py the payment gate + x402 verifier selection
payments/eip3009.py EIP-712 signature recovery and field checks
payments/replay.py single-use nonces, atomic claim
service/
app.py FastAPI app; accepts and pays, then queues
jobs.py the job record (including the PRD) and its store
queue.py reliable queue: atomic reserve, leases, requeue
worker.py pull-based workers; heartbeats and bounded retries
build/pipeline.py APK packaging (x402-gated)
build/signing.py unsigned release builds, and proving they are unsigned
build/scaffold.py generates android/ via `flutter create`
evals/
prds/ 10 PRDs chosen to break things
run.py pass-rate harness
emulator/ Firestore emulator config (a demo-* project; no keys)
说明和限制
- 转义至关重要。 PRD 文本会进入两种生成语言,
每个边界都需要单独转义:
yaml_scalar用于pubspec.yaml,dart_string用于 Dart 字面量。两者都曾在测试中出错,因此都不可或缺。 - Firestore 模拟器需要使用与构建不同的 JDK。
firebase-tools不接受低于 JDK 21 的版本;此处的 Android/Gradle 工具链 固定为 17。二者可以共存——只需为模拟器单独提供 21+ 版本的PATH,其他设置不变—— 但报错看起来像是 Java 问题,而非版本策略问题。如果脚本搜索不到 21+ 版本,请设置EMULATOR_JAVA_HOME。 此外,还需要firebase-tools和主版本号与已安装 Chrome 相匹配的chromedriver。 - stub 分析器无法解析 Dart。 它会强制检查约定并检测 provider 不同步,
但它曾让一个存在 59 个语法错误的项目通过。离线运行显示通过只能作为冒烟测试信号,而非证明;要验证这一点,请使用
--analyzer dart。 - **Cupertino 是一整套模板系列,**并非替换根组件即可——
CupertinoApp不提供MaterialLocalizations,因此其中的 Material 组件会在运行时抛出异常。 - 冒烟测试使用 stub provider。 它们能证明屏幕可以构建且其数据层可以解析;但无法证明导航有效,也无法证明写入操作能够到达 Firestore。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。