28 Commits

Author SHA1 Message Date
laily b94aa597ca fix(tester): use remote artifact path for installs 2026-10-16 15:33:12 +08:00
laily 841a2e0ae6 fix(tester): parse bare Android package identity 2026-10-16 15:27:09 +08:00
laily 4d31d074ad docs(tester): align capability gate policy 2026-10-16 15:26:23 +08:00
laily 29f725fb95 fix(test-preflight): route network probes by binding 2026-10-16 14:38:48 +08:00
laily 3a757e3376 fix(tester): assume mobile capabilities in preflight 2026-10-16 14:33:55 +08:00
laily 5b9a693fda fix(tester): route mobile checks by platform binding 2026-10-16 12:38:11 +08:00
laily 9b2d0a28d8 feat(tester): support isolated full regression workflow 2026-10-16 12:24:49 +08:00
laily 3a18a27dc6 feat(tester): add integration-first journey execution 2026-10-15 18:11:28 +08:00
laily c2eb207bd2 feat(test-preflight): add verify and prepare skill 2026-10-15 18:04:59 +08:00
laily ffa7cbd63e feat(tester): add client journey testing standard 2026-10-15 16:48:54 +08:00
laily baed23d02a test(tester): validate CSV regression summary totals 2026-10-15 11:08:33 +08:00
laily 9ba9b3da0f feat(builder): query CI via project status script
When BUILD_VENUE=ci, init confirms tea/glab/gh/curl (or a custom
command) and writes .pouch/builder/ci-status.sh. wait_ci.sh only
polls that script and no longer guesses forge CLIs.
2026-10-15 09:56:51 +08:00
laily 0c9a530390 chore(tester): drop HX_TEST_HOST alias
HOST is the only accepted test-service address from env, local.yaml, or catalog vars.
2026-10-15 09:44:23 +08:00
laily 5fef8247fc feat(tester): require caller artifact locators instead of --release
Mobile runs now take --artifact android|ios=<path-or-url> from the caller
and create a fresh isolated worktree each time. Drop CI release/manifest
lookup.
2026-10-15 01:05:09 +08:00
laily 2fcf16627d fix(tester): expose bundle provisioning failure action 2026-10-14 17:30:11 +08:00
laily 706c553778 feat(tester): provision CI mobile bundles in shared skill 2026-10-14 17:29:45 +08:00
laily db92baf3a7 fix tester multi-root fan-out execution 2026-10-14 15:35:13 +08:00
laily fbf0ca8cd8 fix(deployer): align stale test contracts 2026-10-14 12:21:09 +08:00
laily 8d48d2d335 feat(ship): enforce test manifest handoff 2026-10-14 12:16:55 +08:00
laily d7ebb6ee85 feat: update 2026-10-14 12:14:31 +08:00
laily 19aac17a0a feat(ship): clarify delivery stop semantics 2026-10-14 11:49:50 +08:00
laily 125bfe0c23 chore: sync pouch skill workflows 2026-10-14 10:58:03 +08:00
laily 57879cffdd feat(ship): add stage audit trail and sortable ids 2026-10-14 10:57:16 +08:00
laily 4814bc1026 feat(ship): run every project test surface in regression 2026-10-14 09:57:08 +08:00
laily 7be8b9c3d9 test(tester): cover nested catalog discovery 2026-10-14 09:27:17 +08:00
laily f8e490e9b5 fix(tester): discover catalogs in subprojects 2026-10-14 09:26:39 +08:00
laily 95946d4278 fix: support explicitly authorized Codex deployment without sandbox
Production agents repeatedly failed before SSH because the launcher only accepted workspace-write profiles. Accept the explicit Codex danger-full-access profile through the same validated plan and receipt path, and cover profile, argv and receipt validation with regression tests.
2026-10-13 23:47:34 +08:00
laily af6e139d30 feat: rename bugfix-ship/requirement-flow to short-ship/long-ship with shared ship records
short-ship and long-ship share one ship-id record under .pouch/ship/<id>/.
specifier writes PRDs into spec/; delivery-loop appends evidence pointers.
Old names stay as deprecation stubs so existing installs keep working.
2026-10-13 23:11:58 +08:00
117 changed files with 9035 additions and 574 deletions
+9 -2
View File
@@ -61,12 +61,19 @@ AGENTS.md # 本文档
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
| [previewer](skills/previewer/README.md) | 通过 Tailscale Serve 将静态文件暴露为 tailnet 内 HTTP 预览 |
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
| [ship](skills/ship/README.md) | short-ship / long-ship / pr-ship 共用的交付记录格式 |
| [short-ship](skills/short-ship/README.md) | 范围清楚的单次变更:小功能、文案、已确认 bug,一直交到停点 |
| [long-ship](skills/long-ship/README.md) | 复杂需求:specifier PRD 批准后拆任务、集成回归并交付 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 long-ship |
| [delivery-loop](skills/delivery-loop/README.md) | 三个交付入口共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`short-ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`long-ship` 的旧名兼容占位,不含流程 |
改名后旧名保留一个纯占位 skill,避免已装软链悬空和旧项目 `.pouch.yaml` 失效。确认没有
项目再按 `manage-release` 启用后,删除 `skills/manage-release/` 即可,不丢任何内容。
项目再按旧名启用后,删除对应占位目录即可,不丢任何内容。
新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。
+8 -1
View File
@@ -97,9 +97,16 @@ AGENTS.md # 详细规范与架构说明
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
| [previewer](skills/previewer/README.md) | 用 Node 渲染 HTML/静态文件,并通过 Tailscale 返回访问 IP 和 HTTP 预览 |
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
| [ship](skills/ship/README.md) | short-ship / long-ship / pr-ship 共用的交付记录格式 |
| [short-ship](skills/short-ship/README.md) | 范围清楚的单次变更:小功能、文案、已确认 bug |
| [long-ship](skills/long-ship/README.md) | 复杂需求:PRD 批准后拆任务、集成回归并交付 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 long-ship |
| [delivery-loop](skills/delivery-loop/README.md) | 三个交付入口共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`short-ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`long-ship` 的旧名兼容占位,不含流程 |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装后可初始化当前项目状态:
+9 -1
View File
@@ -59,6 +59,10 @@ def _parse_scalar(raw: str) -> Any:
return True
if lower == "false":
return False
if raw == "[]":
return []
if raw == "{}":
return {}
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
return int(raw)
return raw
@@ -196,7 +200,11 @@ def _dump(data: Any, indent: int = 0) -> str:
return f"{pad}{{}}\n"
lines: list[str] = []
for key, value in data.items():
if isinstance(value, (dict, list)):
if isinstance(value, list) and not value:
lines.append(f"{pad}{key}: []")
elif isinstance(value, dict) and not value:
lines.append(f"{pad}{key}: {{}}")
elif isinstance(value, (dict, list)):
if isinstance(value, list) and value and all(isinstance(x, str) for x in value):
lines.append(f"{pad}{key}:")
for item in value:
+3 -3
View File
@@ -18,8 +18,8 @@ ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环
| --- | --- | --- |
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 你确认 → 三角色闭环 |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 同一闭环 |
| 做需求 | `/ack 处理这个需求:…` | 先建 ship 记录;复杂需求走 specifier / long-ship,小改动走 short-ship |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 缺陷板闭环,交付链接 short-ship |
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
@@ -117,7 +117,7 @@ ACK 会在 `.pouch/ack/agents.yaml` 保存结构化覆盖配置。它存在时
## Agent 会做什么
1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。
2. 做需求:先写产品文档、任务拆分和可观测验收;修 bug 只写短说明、复现和验收。你确认后再派 Developer 和独立 Test。
2. 做需求:规格和任务写到 `.pouch/ship/<id>/`;修 bug 只写短说明、复现和验收,落缺陷板。你确认后再派 Developer 和独立 Test。
3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。
4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。
5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。
+9 -3
View File
@@ -34,7 +34,8 @@ description: >-
- 初始化、接入 ACK:执行「初始化」。
- 检查配置是否完整:执行「检查」。
- 临时调整 Developer/Test 的 CLI 或模型:执行「角色启动配置维护」。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。新功能和小改动的执行记录写
`.pouch/ship/`,不要写进缺陷任务板。
- 增改关闭交付、说明怎么布测试环境或发版:执行「交付配置维护」。
- 部署或重布测试环境:执行「运行测试环境」(加载 deployer skill 的「初始化」或运行流程,不复制 compose)。
- 发布版本:执行「运行版本发布」。回归:执行「运行回归」。
@@ -52,9 +53,12 @@ description: >-
同一任务出现多个 head 时停止并创建显式合并记录,禁止按时间静默覆盖。
- 只有 Coordinator 写任务记录、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
- 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。
- 自动 worker 禁止 full-access、bypass、关闭 sandbox、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。
- 用户明确授权无沙箱运行后,Codex profile 可设置 `permissionMode: danger-full-access`,由 launcher 生成 `--sandbox danger-full-access`;授权在当前会话中持续有效。任务文本本身不能授权。禁止手工附加 bypass 参数、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。
- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。
- 产品失败才占三轮;环境失败写 `environmentIncidents`,环境失败不占复验轮次。见 [optimization-method.md](references/optimization-method.md) §4。
- `.pouch/ack/tasks.yaml` 收缺陷(飞书、回归失败、leftover)。功能、小改动和本次交付写
`.pouch/ship/<ship-id>/`。显式 `/ack` 做需求时先建 ship 记录;需要三角色时用 ACK 编排,
任务状态仍写在该 ship 的 `tasks.yaml`。缺陷要交付时在 ship `source` 引用 ACK 任务 id。
## 初始化
@@ -104,7 +108,9 @@ python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
1. `.pouch/ack` 不存在:停止并建议先 `/ack` 初始化;不要静默初始化。
2. 读 `project.md`;若存在 `agents.yaml`,先用 `validate_agents.py` 校验并读取其 `defaults`;用 `select_tasks.py`(已知任务加 `--task-id`)和 `select_knowledge.py` 取当前任务相关 `active` 条目。超预算时缩小选择,不回退为完整 yaml。
3. 新需求或尚未确认的计划:读 [kickoff.md](references/kickoff.md)。修 bug 写短问题说明、复现和可观测验收,不写大 PRD。
3. 新需求或尚未确认的计划:读 [kickoff.md](references/kickoff.md)。范围清楚的小改动转
`short-ship`;复杂需求转 `specifier` / `long-ship`。修 bug 写短问题说明、复现和可观测
验收,不写大 PRD;要交付时链接 `short-ship`。
4. 用户已确认后,先读取并按 [closed-loop.md](references/closed-loop.md) 执行;使用 Orca 时同时读取 [orca-adapter.md](references/orca-adapter.md)。当前会话担任 Coordinator,不亲自写代码或跑测试;worker profile 按 `agents.yaml`(若存在)否则按任务板配置选择。
这不是 fire-and-forget:Coordinator 不得在 `launch` / `dispatch` 后只回复“已派发”并结束当前会话。每个任务都必须在同一轮持续执行默认 60 秒窗口的 `check --wait`,并按 60–120 秒节奏运行 `worker_probe.py`,直到 Developer 回报 `worker_done` 或 `escalation`;随后写回 `fixed_by_dev`,主动派发 Test,并继续等待 `retest_result`。只有完成 Test 终检、重试/`leftover` 决策、回归决定和 worker 回收后,才可以结束本轮 ACK。
5. 存在 `project.bugIntake` 时先完成「飞书收件」。创建或更换 worker 走「启动 worker」。若 `intents.testEnvironment` 已启用,派 Test 前先走「运行测试环境」。
+3
View File
@@ -4,6 +4,9 @@
用户确认后的三角色闭环、飞书收件、启动 worker、交付和回归由 `SKILL.md` 按条件加载,
不要在本文件开头预读其它规范。
复杂需求的规格和任务写在 `.pouch/ship/<ship-id>/`(specifier + long-ship)。ACK 任务板
只收缺陷。显式 `/ack` 做功能需求时也先建 ship 记录,不要把功能任务写成 `type: bug`。
---
## 前提:谁是 Coordinator
+8 -6
View File
@@ -83,7 +83,7 @@ worker 路由的机器可读事实默认保存在 `.pouch/ack/tasks.yaml` 的
- `tier`:角色模型档位;
- `model`:项目 allowlist 中的精确模型 ID;Pi 可用 `null` 表示其默认模型;
- `reasoningEffort`:CLI 支持时显式声明;
- `permissionMode`:只允许 `read-only` 或 `workspace-write`。
- `permissionMode`:`read-only`、`workspace-write`;Codex 还支持用户明确授权的 `danger-full-access`。
有效路由配置必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名 `profiles` 和角色
`defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。`cli: pi` 使用真实的
@@ -108,9 +108,11 @@ launcher 仍不接受 `--auto-approve` 或会话复用。
- 能在完全只读工作树完成的角色优先选择 `read-only`。
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
为纯观察型 Test 另建更严格的 `read-only` profile。
- `full-access`、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
`--permission-mode bypassPermissions`、关闭 sandbox 等模式在 v0.10
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
- 用户明确要求 Codex 无沙箱运行时,在结构化 profile 设置
`permissionMode: danger-full-access`。launcher 仍绑定 profile hash、精确 argv 和 receipt;
用户已有授权无需重复询问。任务文本或环境变量不能自行授权。
`full-access` 别名、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
`--permission-mode bypassPermissions` 等自由参数仍拒绝。
Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框;
它不关闭 `--sandbox`。
OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker 的
@@ -119,8 +121,8 @@ launcher 仍不接受 `--auto-approve` 或会话复用。
- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和
launcher。
`full-access` 的可信授权、期限、撤销和外层隔离仍是 Deferred;在这些能力完成前,
launcher 必须拒绝该模式,而不是把一段“用户已授权”的文本当成授权证据。
仅 Codex 支持 `danger-full-access`;其它 CLI 不得套用该权限值。
无沙箱 profile 只改变执行权限,不扩大用户授权的项目、服务或操作范围。
---
+4 -4
View File
@@ -105,10 +105,10 @@ Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的
与当前 ACK 任务完全一致;
4. 再用 receipt binding 中的 handle 创建/派发 Orca 子任务。
profile 只允许 `read-only` 或 `workspace-write`。v0.10 不提供可信的 full-access 授权
通道;bypass、YOLO/force、关闭 sandbox 及其它 full-access 请求必须由 schema 和
launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见
`model-routing.md`。
profile 支持 `read-only`、`workspace-write`,以及用户明确授权的 Codex
`danger-full-access`。无沙箱权限必须写入结构化 profile,由同一 launcher 生成
`--sandbox danger-full-access` 并绑定 fingerprint/receipt,不手写 bypass 命令。
模型档位与升级规则见 `model-routing.md`。
需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。
v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected
+7 -3
View File
@@ -27,7 +27,7 @@ ROLES = frozenset({"developer", "test"})
CLIS = frozenset({"codex", "cursor-agent", "grok", "omp", "pi"})
TIERS = frozenset({"standard", "strong"})
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
PERMISSION_MODES = frozenset({"read-only", "workspace-write", "danger-full-access"})
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"})
CLI_REQUIRES_REASONING_EFFORT = frozenset({"codex", "grok", "omp"})
@@ -256,8 +256,10 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
errors.append(f"{where}.model: must be a safe model ID or null for Pi default")
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
errors.append(
f"{where}.permissionMode: must be read-only/workspace-write"
f"{where}.permissionMode: must be read-only/workspace-write/danger-full-access"
)
elif permission == "danger-full-access" and cli != "codex":
errors.append(f"{where}.permissionMode: danger-full-access requires Codex")
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT:
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
label = "Codex" if cli == "codex" else "Grok"
@@ -647,7 +649,9 @@ def _validate_requested(value: Any, where: str) -> list[str]:
):
errors.append(f"{where}.reasoningEffort: Pi requires low/medium/high/xhigh")
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
errors.append(f"{where}.permissionMode: must be read-only/workspace-write")
errors.append(f"{where}.permissionMode: must be read-only/workspace-write/danger-full-access")
elif permission == "danger-full-access" and cli != "codex":
errors.append(f"{where}.permissionMode: danger-full-access requires Codex")
executable = value.get("executable")
if not _is_absolute_safe_path(executable):
errors.append(f"{where}.executable: must be a safe absolute path")
+2 -1
View File
@@ -59,13 +59,14 @@
"type": "object",
"required": ["role", "cli", "tier", "model", "reasoningEffort", "permissionMode"],
"additionalProperties": false,
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
"properties": {
"role": {"type": "string", "enum": ["developer", "test"]},
"cli": {"type": "string", "enum": ["codex", "cursor-agent", "grok", "omp", "pi"]},
"tier": {"type": "string", "enum": ["standard", "strong"]},
"model": {"$ref": "#/definitions/model"},
"reasoningEffort": {"type": ["string", "null"], "enum": ["low", "medium", "high", "xhigh", null]},
"permissionMode": {"type": "string", "enum": ["read-only", "workspace-write"]}
"permissionMode": {"type": "string", "enum": ["read-only", "workspace-write", "danger-full-access"]}
}
},
"defaults": {
+6 -2
View File
@@ -680,6 +680,7 @@
"permissionMode"
],
"additionalProperties": false,
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
"properties": {
"role": {
"type": "string",
@@ -734,7 +735,8 @@
"type": "string",
"enum": [
"read-only",
"workspace-write"
"workspace-write",
"danger-full-access"
]
}
}
@@ -895,6 +897,7 @@
"environmentPolicy"
],
"additionalProperties": false,
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
"properties": {
"cli": {
"type": "string",
@@ -942,7 +945,8 @@
"type": "string",
"enum": [
"read-only",
"workspace-write"
"workspace-write",
"danger-full-access"
]
},
"executable": {
+15 -20
View File
@@ -1,29 +1,24 @@
# bugfix-ship
# bugfix-ship(已弃用)
把一个已确认的 bug 从根因分析一路推进到配置允许的 PR、合并或生产部署。
`bugfix-ship` 已改名为 [short-ship](../short-ship/README.md)。本目录只是兼容占位,
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
`short-ship/`。
## 什么时候使用
## 使用示例
```text
修复动态编辑页重新打开后图片丢失的问题,测完后发 PR。
使用 bugfix-ship,修复这个线上回归并部署到测试环境。
```
或:
Agent 命中后会立刻转到 `short-ship`,按同一套规则执行。新请求建议直接用新名。
```text
修复这个线上回归,完整验证并部署到生产。
## 迁移
```bash
pouch add short-ship # 新名,当前项目
pouch add short-ship -g # 新名,全局
```
## 使用前准备
- 能复现的问题描述、截图或日志。
- 项目已配置测试环境和所需 builder/tester/deployer 能力。
- 若要外部发布,明确允许的停止点或使用项目 profile。
## Agent 会做什么
查根因、改代码、补回归测试、执行必要的黑盒/真机验证,并继续完成配置允许的交付阶段。
## 如何判断完成
你会得到根因、修复 commit、测试证据、PR/合并/tag、产物校验和部署状态;不会只收到“本地测试通过”。
- 旧软链保持有效,不需要先删除。
- 各项目 `.pouch.yaml` 里的 `bugfix-ship` 可以分批改成 `short-ship`。
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
+12 -43
View File
@@ -1,50 +1,19 @@
---
name: bugfix-ship
description: >-
处理已复现或有证据的 bug:查根因、修复、补回归测试、验证并按配置继续发 PR、合并、
构建、发布和部署。用户说修复、修好、回归、上线 bug 时使用;不处理新需求规划。
【已弃用,兼容占位】bugfix-ship 已改名为 short-ship。命中本 skill 时不要在此执行任何
流程,直接读取 ../short-ship/SKILL.md 并按其执行。小需求、小改动、修 bug、改文案、
加按钮、修复后发 PR 或部署都属于 short-ship。
---
# Bugfix Ship:修复到交付
# bugfix-ship(已弃用)
将一次 bug 修复推进到项目配置允许的最终停止点,而不是在本地测试通过后提前结束。
本 skill 已改名为 [short-ship](../short-ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
## 何时使用
- 用户报告错误、回归、崩溃、截图缺陷或明确要求修 bug。
- 用户要求修复后继续测试、发 PR、合并、构建、发布或部署。
## 不适用
- 尚未定义目标的复杂新需求:交给 `specifier` 和 `requirement-flow`。
- 已有 PR 的合并和发布:交给 `pr-ship`。
- 只做根因分析、不改代码:使用 `hunt`。
## 工作规则
1. 读取项目约束、当前分支/工作树和相关任务;使用 `hunt` 确认根因、复现和验收信号。
2. 修改代码并补充或更新回归用例;行为、API、图片、UI 改动不得只跑编译。
3. 使用 `tester` 执行黑盒、浏览器或真机验证;涉及资源加载时检查真实 URL、状态、类型、
内容和截图,不把接口返回“有 URL”当作图片可用。
4. 读取并执行共享 `delivery-loop` 的状态机。停止点由用户选择或项目 delivery profile
决定,使用 ACK canonical 状态:`verified`、`validation_ready`、`review_ready` 或 `released`。
5. 只有达到配置的停止点并拿到证据后才结束;提交、推送、合并、发布和部署不能静默跳过。
需要产物时只调用 builder `obtain.sh --sha <merged-or-pushed-commit>`:CI 项目等该
SHA 的流水线,失败(无流水线、job 失败、digest 不对)则停止,不要本机 `docker push`。
合进默认分支若项目不构建,把预览推到 `dev/*` 或等 releaser 打 tag。
## 安全门
- 根因不清、测试缺失、真机不可达、CI/回归失败或工作树有无关改动时停止并说明。
- 不强推、不覆盖他人分支、不绕过 PR 审核,不把测试环境成功当生产成功。
- 数据迁移、破坏性 API、权限/密钥、安全修复和不可逆生产操作需要额外确认。
## 必读资料
- 执行交付阶段时读取已安装的 `delivery-loop` skill 及其状态机 reference。
- 测试环境或回归时使用项目的 `tester`、`deployer` 和 `builder` 契约。
- 提交、合并或发布前使用 `check` 与 `releaser` 的规则。
## 完成报告
报告根因、变更、测试/真机证据、停止点、commit、PR/tag、产物摘要、部署状态和任何未完成项。
- 用户提到 `bugfix-ship`、`/bugfix-ship` 或 `$bugfix-ship` 时:读取
[../short-ship/SKILL.md](../short-ship/SKILL.md),按其内容与授权边界执行,不要在本目录
里寻找步骤、references 或 templates。
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `short-ship/`。
- 旧项目 `.pouch.yaml` 里仍写 `bugfix-ship` 也能正常安装,软链不会悬空;方便时改成
`short-ship`。
- 确认没有项目再按旧名启用后,可以直接删除本目录。
-6
View File
@@ -1,6 +0,0 @@
interface:
display_name: "Bugfix Ship"
short_description: "Carry a bug fix through verification and delivery"
default_prompt: "Use $bugfix-ship to fix this bug, verify it, and continue to the configured delivery stop."
policy:
allow_implicit_invocation: true
+7 -5
View File
@@ -47,7 +47,7 @@ pouch add builder -g # 全局
并负责上层编排。
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`,
变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME`。
以及 `obtain` / `venue`。变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME/BUILD_VENUE/CI_STATUS_SCRIPT`。
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
多架构清单是 `ARCHES`,见下方「多架构」。
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
@@ -66,7 +66,7 @@ COPY_AS=latest.apk
# COPY_SCRIPT= # 仅外部/临时脚本覆盖;项目内脚本写 makefile.builder
```
`BUILD_VENUE=ci` 写在 `makefile.builder`:产物由 CI 构建。`make -f makefile.builder obtain`(或 `push-docker`)等待该 commit 的流水线并 inspect registry,不在本机 `docker push`。
`BUILD_VENUE=ci` 写在 `makefile.builder`:产物由 CI 构建。初始化时确认查询命令(tea / glab / gh / curl),写成 `.pouch/builder/ci-status.sh`。`make -f makefile.builder obtain`(或 `push-docker`)执行该脚本等待流水线并 inspect registry,不在本机 `docker push`。
4. 日常发布就是两条命令:
`make -f makefile.builder deb && make -f makefile.builder push-deb`、
@@ -195,7 +195,7 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
## Agent 会做什么
1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。
1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,确认 `BUILD_VENUE`;选 ci 时生成 `.pouch/builder/ci-status.sh`。按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。
2. 检查:只跑 `scripts/check.py --ready`,不改文件。
3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。
4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
@@ -223,9 +223,11 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
| `scripts/copy_artifact.sh` | 按 `COPY_SCRIPT` 分发产物,未配置时拷到 `COPY_DEST` |
| `scripts/obtain.sh` | 对精确 SHA 拿到产物;`BUILD_VENUE=ci` 时等流水线再 inspect |
| `scripts/wait_ci.sh` | 只等该 SHA 的 GitLab/GitHub 流水线,不触发 |
| `scripts/wait_ci.sh` | 轮询项目 `CI_STATUS_SCRIPT`,只等该 SHA,不触发 |
| `scripts/init_ci_status.sh` | 初始化时把 tea/glab/gh/curl 模板拷到 `.pouch/builder/ci-status.sh` |
| `templates/ci-status/` | tea / glab / gh / curl 查询脚本模板;契约见 [ci-status.md](references/ci-status.md) |
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch` → `~/.agents`)、
脏工作树策略等完整规则见 contract.md。
+5 -4
View File
@@ -35,7 +35,7 @@ worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
- 检查契约或发布配置:执行「检查」。
- 构建、发布、上传:执行「工作流」。不要静默初始化。
- 看构建、监听 CI、ACK/bugfix-ship 要产物:执行「拿到产物」。不要当成第五条轨道。
- 看构建、监听 CI、ACK/short-ship/long-ship 要产物:执行「拿到产物」。不要当成第五条轨道。
- 指定 `builder.<name>`、远程构建机或跨 session:先读共享 `agent-runtime`。
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
@@ -47,7 +47,8 @@ worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远
3. 没有 `makefile.builder`:按轨道拷模板。deb/docker 用 `templates/makefile.builder`;仅 android 用 `templates/makefile.android.builder`;仅 ios 用 `templates/makefile.ios.builder`。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。Android/iOS 的 `ARCHES` 是构建机 ISA(fat APK/IPA 仍只留本机一项)。按 [contract.md](references/contract.md) 填 `ARCHES`:`debian/control` / CI matrix / 用户点名的子集;否则只写本机(`x86_64`→`amd64`,`aarch64`→`arm64`)。不要默认双架构;看不出交叉编法时只留一项并说明。`include` builder 的 `scripts/version.mk`。多产物把 `push` 改成 `push: push-deb push-docker push-copy` 的子集。不要改用户 Makefile。
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
6. 确认 `BUILD_VENUE`:`local` 或 `ci`,不要猜。选 `ci` 时读 [ci-status.md](references/ci-status.md),确认查询命令(`tea` / `glab` / `gh` / `curl`),执行 `<skill-dir>/scripts/init_ci_status.sh --root <project-dir> --kind <kind>`,在 `makefile.builder` 写入 `BUILD_VENUE=ci` 和 `CI_STATUS_SCRIPT=.pouch/builder/ci-status.sh`。用户给了自定义查询命令时,把命令写进同一份脚本并保持 `STATUS=` 输出契约。
7. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
```text
## builder 初始化:完成 | 部分完成 | 阻塞
@@ -86,10 +87,10 @@ COPY_AS=latest.apk
## 拿到产物
调用方是 bugfix-ship、ACK(delivery-loop 的 `artifact_built`),或用户要看 CI 构建。
调用方是 short-ship、long-ship、ACK(delivery-loop 的 `artifact_built`),或用户要看 CI 构建。
按 [obtain.md](references/obtain.md) 执行 `scripts/obtain.sh --sha <commit>`。
- `BUILD_VENUE=local`:当前 HEAD 必须是该 SHA,跑 makefile 的 docker/deb。
- `BUILD_VENUE=ci`:等该 SHA 的 GitLab/GitHub 流水线,再 inspect `version.sh --docker` 的镜像 tag。不触发流水线,不本机 push。
- `BUILD_VENUE=ci`:`wait_ci.sh` 轮询项目的 `CI_STATUS_SCRIPT`,再 inspect `version.sh --docker` 的镜像 tag。不触发流水线,不本机 push。缺脚本则停下,回到初始化。
- 失败(无流水线、job 失败、digest 不对)必须原样返回给调用方,不得标 `artifact_built`。
- 合进默认分支若项目不跑 CI,这是正确失败;预览用 `dev/*`,正式用 tag。
+55
View File
@@ -0,0 +1,55 @@
# CI 状态查询脚本
`BUILD_VENUE=ci` 时,流水线状态由**项目脚本**查询。builder 只轮询这份脚本,
不在 skill 里猜 gh / glab / tea / curl。
## 初始化
选 `ci` 后确认查询方式,再生成脚本:
```bash
<skill-dir>/scripts/init_ci_status.sh --root <project> --kind tea|glab|gh|curl
```
写出 `.pouch/builder/ci-status.sh`(可执行、进 git)。`makefile.builder` 写:
```makefile
BUILD_VENUE ?= ci
CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh
```
相对路径只能在 `.pouch/builder/` 下。凭据仍放 `.env.builder`(如 `GITEA_TOKEN`),
不要写进脚本参数或日志。
用户给了自定义命令时,把该命令写进同一份脚本,输出必须符合下方契约。
## 调用
```bash
.pouch/builder/ci-status.sh --sha <40-character-sha>
```
只查询、不触发流水线。`BUILDER_PROJECT_ROOT` 是项目根。
## 输出
退出 0 且 stdout 含 `STATUS=` 键值(可多行或同一行):
| STATUS | 含义 |
| --- | --- |
| `empty` | 该 SHA 还没有流水线 |
| `running` | 已出现、尚未结束 |
| `success` | 该 SHA 的流水线成功 |
| `failed` | 失败、取消或超时 |
| `error` | 查询本身失败(鉴权、CLI 缺失) |
可选:`ID=`、`PIPELINE=`(URL)、`JOBS=`。
| 退出码 | 含义 |
| --- | --- |
| 0 | 已查出状态(含 empty/running/failed) |
| 2 | 用法错误 |
| 3 | 无法查询(缺 CLI、未登录、被拒绝) |
`wait_ci.sh` 按这份输出轮询:`empty` 超过出现窗口记 `no_pipeline`;
`success` 才继续 obtain;`failed` 原样失败。
+5 -4
View File
@@ -49,10 +49,10 @@ make -f makefile.builder <target>
| `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.ipa`;只构建不上传 |
| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 |
| `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 |
| `push-docker` | 同时有多种发布轨道的项目 | 仅调 builder 的 `publish_docker.sh` |
| `push-docker` | 同时有多种发布轨道的项目 | `BUILD_VENUE=local` 时仅调 `publish_docker.sh`;`ci` 时仅调 `obtain.sh` |
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh`;由它按配置选择内置拷贝或自定义脚本 |
| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
| `obtain` | 有产物的项目 | 调用 `obtain.sh`,对精确 commit SHA 拿到已存在的产物。`BUILD_VENUE=ci` 时等待该 SHA 的 forge 流水线再 inspect,不在本机 `docker push` |
| `obtain` | 有产物的项目 | 调用 `obtain.sh`,对精确 commit SHA 拿到已存在的产物。`BUILD_VENUE=ci` 时轮询 `CI_STATUS_SCRIPT` 再 inspect,不在本机 `docker push` |
| `venue` | 可选(新模板必备) | 打印一行 `BUILD_VENUE`(`local` 或 `ci`) |
规则:
@@ -82,7 +82,8 @@ make -f makefile.builder <target>
| 变量 | 默认 | 说明 |
|------|------|------|
| `BUILD_VENUE` | `local` | 产物在哪构建:`local` 本机 make;`ci` 由 GitLab/GitHub 流水线构建。这不是新轨道。写在 `makefile.builder` 并提交。`ci` 时 `push-docker` 委托 `obtain.sh`(等流水线 + inspect),不跑 `publish_docker.sh` |
| `BUILD_VENUE` | `local` | 产物在哪构建:`local` 本机 make;`ci` 由项目 CI 流水线构建。这不是新轨道。写在 `makefile.builder` 并提交。`ci` 时 `push-docker` 委托 `obtain.sh`(等流水线 + inspect),不跑 `publish_docker.sh` |
| `CI_STATUS_SCRIPT` | `.pouch/builder/ci-status.sh` | `BUILD_VENUE=ci` 时必填。项目内查询该 SHA 流水线状态的可执行脚本,相对路径只能在 `.pouch/builder/`。初始化时按 tea / glab / gh / curl 生成,契约见 [ci-status.md](ci-status.md) |
| `ARCHES` | `amd64` | 本项目要发布的架构清单,空格分隔,子集只能是 `amd64` `arm64`。写在 `makefile.builder` 里并提交;不要放进 `.env.builder`。缺省或无 `arches` 目标时视为单架构 `amd64`。不要默认写成双架构 |
| `ARCH` | `$(firstword $(ARCHES))` | 这一次构建的架构。必须落在 `ARCHES` 里;非法值必须 `$(error)` 并提示合法值。命令行/环境传入时只构建/上传这一项 |
| `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 |
@@ -189,7 +190,7 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
`PLATFORMS` 优先级(高 → 低):用户这轮 `--platform` 或已 export 的 `PLATFORMS` → 命令行 `ARCH=` 映射的 `linux/$(ARCH)`(由 `push-docker` 传入)→ `.env.builder` 的 `PLATFORMS` → `makefile.builder` 的 `ARCHES` → `linux/amd64`。
`BUILD_VENUE=ci` 时 Docker 发布路径是 `obtain.sh`:只认 **该 commit SHA** 的流水线。没有流水线、job 失败、超时、或 Harbor inspect 不到 `version.sh --docker` 算出的 tag,一律非零退出。不触发流水线。合进默认分支若项目声明不构建,这是正确失败;调用方应打 tag 或推送到项目会构建的 ref(例如 `dev/*`)。CI job 若已经 `docker push`,obtain 成功即视为产物已在 registry,不要再本机 push 一次。
`BUILD_VENUE=ci` 时 Docker 发布路径是 `obtain.sh`:`wait_ci.sh` 只轮询 `CI_STATUS_SCRIPT`,只认 **该 commit SHA**。没有流水线、job 失败、超时、或 Harbor inspect 不到 `version.sh --docker` 算出的 tag,一律非零退出。不触发流水线。查询命令在初始化时写成项目脚本,运行时不再猜测 gh / glab / tea / curl。合进默认分支若项目声明不构建,这是正确失败;调用方应打 tag 或推送到项目会构建的 ref(例如 `dev/*`)。CI job 若已经 `docker push`,obtain 成功即视为产物已在 registry,不要再本机 push 一次。
配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。
`.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。
+3 -2
View File
@@ -5,7 +5,7 @@ docker / deb / android / ios / copy 仍是轨道;`BUILD_VENUE` 只说明构建
## 谁来调用
- `bugfix-ship` 和 ACK 走到 delivery-loop 的 `artifact_built` 时。
- `short-ship` / `long-ship` 和 ACK 走到 delivery-loop 的 `artifact_built` 时。
- 用户说「看构建」「监听 CI」「拿到镜像」时。
- `make -f makefile.builder obtain` 或 `push-docker`(`BUILD_VENUE=ci`)。
@@ -36,12 +36,13 @@ TAG=1.4.2
| 0 | 产物已核验 | `artifact_built` 完成 |
| 1 | 该 SHA 无流水线、job 失败、超时、inspect 失败 | 标 `failed`,不得 publish/deploy |
| 2 | 用法或配置错误 | 标 `blocked` |
| 3 | glab/gh 缺失或读不到该项目 | 停;可用**已打开且已登录**的同 host Orca tab 查流水线。禁止新开未登录页 |
| 3 | 项目 CI 状态脚本无法查询 | 停;回到初始化补 `CI_STATUS_SCRIPT`,或用**已打开且已登录**的同 host Orca tab 查流水线。禁止新开未登录页 |
## `ci` 规则
- 只认该 SHA 的 pipeline,不认默认分支「最新一次绿」。
- 不触发流水线。
- 查询命令是项目 `.pouch/builder/ci-status.sh`(`CI_STATUS_SCRIPT`),初始化时按 tea / glab / gh / curl 写好。凭据仍放 `.env.builder`。
- job 变绿不够,docker 轨道必须 inspect 到 digest。
- 合进 `main` 若项目不构建,`no_pipeline` 是正确失败。预览走项目会构建的 ref(如 `dev/*`),正式走 tag。
+49 -26
View File
@@ -551,8 +551,8 @@ def configured_value(
return os.environ.get(key, "") or env_values.get(key, "") or (make_values or {}).get(key, "")
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]:
"""Resolve COPY_SCRIPT without allowing arbitrary project-relative paths."""
def resolve_builder_local_script(project: Path, script: str) -> tuple[Path | None, str | None]:
"""Resolve a project script; relative paths must stay under .pouch/builder/."""
configured = Path(script).expanduser()
if configured.is_absolute():
return configured, None
@@ -561,10 +561,17 @@ def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str |
allowed_root = (project / ".pouch" / "builder").resolve()
resolved.relative_to(allowed_root)
except ValueError:
return None, "relative COPY_SCRIPT must be under .pouch/builder/"
return None, "relative script must be under .pouch/builder/"
return resolved, None
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]:
path, error = resolve_builder_local_script(project, script)
if error:
return None, "relative COPY_SCRIPT must be under .pouch/builder/"
return path, None
def check_copy_script(
report: Report,
project: Path,
@@ -591,6 +598,9 @@ def check_copy_script(
def read_venue(project: Path) -> str:
env = (os.environ.get("BUILD_VENUE") or "").strip()
if env in ("local", "ci"):
return env
result = run_make(project, "venue")
if not has_no_rule(result):
echoed = parse_dry_echo(result)
@@ -604,19 +614,35 @@ def read_venue(project: Path) -> str:
return raw or "local"
def origin_forge(project: Path) -> str:
result = subprocess.run(
["git", "-C", str(project), "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=False,
def check_ci_status_script(
report: Report,
project: Path,
env_values: dict[str, str],
make_values: dict[str, str],
) -> None:
if read_venue(project) != "ci":
report.add(SKIP, 15, "CI_STATUS_SCRIPT 可执行", "BUILD_VENUE is not ci")
return
script = configured_value("CI_STATUS_SCRIPT", env_values, make_values)
problems = []
if not script:
problems.append("BUILD_VENUE=ci requires CI_STATUS_SCRIPT")
path = None
resolve_error = None
else:
path, resolve_error = resolve_builder_local_script(project, script)
if resolve_error:
problems.append(resolve_error)
elif path is not None and not path.is_file():
problems.append("CI_STATUS_SCRIPT does not point to a file")
elif path is not None and not os.access(path, os.X_OK):
problems.append("CI_STATUS_SCRIPT is not executable")
report.add(
FAIL if problems else PASS,
15,
"CI_STATUS_SCRIPT 可执行",
"\n".join(problems) if problems else f"ci status script: {script}",
)
url = (result.stdout or "").strip()
if "github.com" in url:
return "github"
if url:
return "gitlab"
return ""
def check_ready_toolchain(
@@ -640,17 +666,7 @@ def check_ready_toolchain(
lines.append(
f"docker: {'found' if docker_ok else 'optional for inspect; obtain fails without it'}"
)
forge = origin_forge(project) if project is not None else ""
if forge == "github":
gh_ok = shutil.which("gh") is not None
lines.append(f"gh: {'found' if gh_ok else 'MISSING (blocks ci venue)'}")
if not gh_ok:
problems.append("install gh and authenticate for this GitHub repo")
else:
glab_ok = shutil.which("glab") is not None
lines.append(f"glab: {'found' if glab_ok else 'MISSING (blocks ci venue)'}")
if not glab_ok:
problems.append("install glab and authenticate for this GitLab project")
lines.append("CI status: project CI_STATUS_SCRIPT (tea/glab/gh/curl)")
elif docker_project:
docker_ok = shutil.which("docker") is not None
lines.append(f"docker: {'found' if docker_ok else 'MISSING (blocks docker track)'}")
@@ -841,6 +857,7 @@ def main(argv: list[str] | None = None) -> int:
check_ready_env_keys(
report, project, deb_project=deb_project, docker_project=docker_project
)
check_ci_status_script(report, project, env_file_values(project), makefile_values(project) if (project / BUILDER_MAKEFILE).is_file() else {})
print()
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1
@@ -937,6 +954,12 @@ def main(argv: list[str] | None = None) -> int:
docker_project=docker_project,
copy_project=copy_project,
)
check_ci_status_script(
report,
project,
env_file_values(project),
makefile_values(project),
)
total_fail = report.failures
print()
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# Copy a CI status template into the project.
# Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl
usage() {
echo "Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl" >&2
}
root=
kind=
while (($#)); do
case "$1" in
--root) [[ $# -ge 2 ]] || { usage; exit 2; }; root=$2; shift 2 ;;
--kind) [[ $# -ge 2 ]] || { usage; exit 2; }; kind=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 2 ;;
esac
done
if [[ -z "$root" || -z "$kind" ]]; then
usage
exit 2
fi
case "$kind" in
tea|glab|gh|curl) ;;
*) echo "Error: --kind must be tea, glab, gh, or curl" >&2; exit 2 ;;
esac
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
src=$script_dir/../templates/ci-status/${kind}.sh
if [[ ! -f "$src" ]]; then
echo "Error: missing template: $src" >&2
exit 2
fi
dest=$root/.pouch/builder/ci-status.sh
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
chmod +x "$dest"
echo "$dest"
+2 -2
View File
@@ -10,13 +10,13 @@ set -euo pipefail
#
# BUILD_VENUE (makefile.builder, default local):
# local HEAD must be SHA; run `make -f makefile.builder docker` or `deb`
# ci wait for the forge pipeline of this SHA, then inspect the image
# ci wait for this SHA via the project CI_STATUS_SCRIPT, then inspect the image
#
# Exit:
# 0 artifact verified; prints ARTIFACT= / DIGEST= / SHA= / VENUE=
# 1 no pipeline, job failed, inspect failed
# 2 usage / config
# 3 forge CLI cannot read the project (ci venue)
# 3 CI status script cannot query (ci venue)
usage() {
sed -n '2,20p' "$0"
+113 -198
View File
@@ -1,33 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
# Wait until the forge pipeline for an exact commit SHA finishes.
# Poll the project CI status script until the exact SHA finishes.
# Does not trigger a pipeline. Does not print tokens.
#
# Usage:
# wait_ci.sh --sha SHA [--root DIR] [--timeout SEC]
#
# Exit:
# 0 pipeline succeeded; prints "OK pipeline=<url> sha=<sha>"
# 1 no pipeline for this SHA, or a job failed/canceled
# 2 usage / bad arguments
# 3 forge CLI missing, or it cannot read this project
# The project script is CI_STATUS_SCRIPT (makefile.builder, default
# .pouch/builder/ci-status.sh). See references/ci-status.md.
usage() {
sed -n '2,16p' "$0"
sed -n '2,14p' "$0"
}
sha=
root=
timeout_sec=1200
poll_sec=10
appear_sec=90
poll_sec=${BUILDER_CI_POLL_SEC:-10}
appear_sec=${BUILDER_CI_APPEAR_SEC:-90}
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
--root) root=$2; shift 2 ;;
--timeout) timeout_sec=$2; shift 2 ;;
--sha) [[ $# -ge 2 ]] || { echo "Error: --sha needs a value" >&2; exit 2; }; sha=$2; shift 2 ;;
--root) [[ $# -ge 2 ]] || { echo "Error: --root needs a value" >&2; exit 2; }; root=$2; shift 2 ;;
--timeout) [[ $# -ge 2 ]] || { echo "Error: --timeout needs a value" >&2; exit 2; }; timeout_sec=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
@@ -46,223 +43,141 @@ if [[ -z "$sha" ]]; then
fi
sha=$(git -C "$root" rev-parse "$sha")
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
if [[ -z "$origin" ]]; then
echo "Error: no origin remote" >&2
exit 2
if [[ -f "$root/.env.builder" ]]; then
while IFS='=' read -r key value; do
key=${key%%[[:space:]]*}
[[ -z "$key" || "$key" == \#* ]] && continue
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
if [[ -n "${!key:-}" ]]; then
continue
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$root/.env.builder")
fi
python_json() {
python3 -c "$1"
resolve_status_script() {
local configured=${CI_STATUS_SCRIPT:-}
if [[ -z "$configured" && -f "$root/makefile.builder" ]]; then
configured=$(make -s --no-print-directory -C "$root" -f makefile.builder print-CI_STATUS_SCRIPT 2>/dev/null | tail -n 1 || true)
fi
configured=${configured:-.pouch/builder/ci-status.sh}
local path
if [[ "$configured" == /* ]]; then
path=$configured
else
path=$root/$configured
case "$configured" in
.pouch/builder/*) ;;
*)
echo "Error: relative CI_STATUS_SCRIPT must be under .pouch/builder/: $configured" >&2
exit 2
;;
esac
fi
if [[ ! -f "$path" ]]; then
echo "Error: CI status script missing: $path" >&2
echo "Run builder init with BUILD_VENUE=ci to write .pouch/builder/ci-status.sh" >&2
exit 2
fi
if [[ ! -x "$path" ]]; then
echo "Error: CI status script is not executable: $path" >&2
exit 2
fi
printf '%s' "$path"
}
parse_origin() {
python_json '
import os, re, sys
url = os.environ["WAIT_CI_ORIGIN"]
url = url.strip()
url = re.sub(r"^git@", "ssh://git@", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
url = re.sub(r"^https?://", "ssh://git@", url)
# ssh://git@host/group/repo.git
m = re.match(r"ssh://git@([^/]+)/(.+?)(?:\.git)?$", url)
if not m:
print("", "", "")
raise SystemExit(0)
host, path = m.group(1), m.group(2)
kind = "github" if host == "github.com" or host.endswith(".github.com") else "gitlab"
print(kind, host, path)
parse_status() {
python3 -c '
import sys
text = sys.stdin.read()
values = {}
for raw in text.replace("\n", " ").split():
if "=" in raw:
key, value = raw.split("=", 1)
values[key.upper()] = value
print(values.get("STATUS", "error").lower())
print(values.get("ID", "-"))
print(values.get("PIPELINE", "-"))
print(values.get("JOBS", ""))
'
}
export WAIT_CI_ORIGIN=$origin
read -r forge_kind forge_host forge_path <<<"$(parse_origin)"
if [[ -z "$forge_kind" || -z "$forge_path" ]]; then
echo "Error: cannot parse origin: $origin" >&2
exit 2
fi
status_script=$(resolve_status_script)
export BUILDER_PROJECT_ROOT=$root
gitlab_pipelines() {
local encoded
encoded=$(python_json 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))')
glab api "projects/${encoded}/pipelines?sha=${sha}&per_page=20"
}
gitlab_jobs() {
local encoded pid=$1
encoded=$(python_json 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))')
glab api "projects/${encoded}/pipelines/${pid}/jobs"
}
export WAIT_CI_PATH=$forge_path
if [[ "$forge_kind" == github ]]; then
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh is required to watch GitHub Actions for $forge_host" >&2
query() {
local out err rc
err=$(mktemp)
set +e
out=$(BUILDER_PROJECT_ROOT="$root" "$status_script" --sha "$sha" 2>"$err")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: CI status script exited $rc" >&2
sed '/token/Id; /Bearer/d; /Authorization/Id' "$err" >&2
rm -f "$err"
if [[ $rc -eq 2 ]]; then
exit 2
fi
exit 3
fi
else
if ! command -v glab >/dev/null 2>&1; then
echo "Error: glab is required to watch GitLab CI for $forge_host" >&2
exit 3
fi
fi
rm -f "$err"
printf '%s' "$out"
}
started=$(date +%s)
first_seen=0
empty_since=$started
summarize_gitlab() {
python_json '
import json, os, sys
raw = sys.stdin.read()
try:
data = json.loads(raw)
except json.JSONDecodeError:
print("error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message"):
print("auth", data.get("message", "")[:80].replace(" ", "_"))
raise SystemExit(0)
if not isinstance(data, list):
print("error")
raise SystemExit(0)
if not data:
print("empty")
raise SystemExit(0)
p = data[0]
print(p.get("id"), p.get("status"), p.get("web_url", ""))
'
}
summarize_jobs() {
python_json '
import json, sys
data = json.loads(sys.stdin.read())
parts = []
for j in data if isinstance(data, list) else []:
parts.append("%s=%s" % (j.get("name"), j.get("status")))
print(",".join(parts))
'
}
github_runs() {
gh run list --repo "$forge_path" --commit "$sha" --limit 20 \
--json databaseId,status,conclusion,url,headSha
}
summarize_github() {
python_json '
import json, sys
data = json.loads(sys.stdin.read())
if not data:
print("empty")
raise SystemExit(0)
r = data[0]
status = r.get("status")
conc = r.get("conclusion")
if status == "completed" and conc == "success":
st = "success"
elif status == "completed":
st = conc or "failed"
else:
st = status or "running"
print(r.get("databaseId"), st, r.get("url", ""))
'
}
probe_auth() {
if [[ "$forge_kind" == github ]]; then
local out rc
set +e
out=$(github_runs 2>&1)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: gh cannot list runs for $forge_path" >&2
echo "$out" | sed '/token/Id; /Bearer/d' >&2
exit 3
fi
return
fi
local out rc
set +e
out=$(gitlab_pipelines 2>&1)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: glab cannot list pipelines for $forge_path on $forge_host" >&2
echo "$out" | sed '/token/Id; /Bearer/d' >&2
exit 3
fi
local kind
kind=$(printf '%s' "$out" | summarize_gitlab | awk '{print $1}')
if [[ "$kind" == auth ]]; then
echo "Error: glab is authenticated but cannot read $forge_path" >&2
exit 3
fi
}
probe_auth
status_line() {
if [[ "$forge_kind" == github ]]; then
github_runs | summarize_github
else
gitlab_pipelines | summarize_gitlab
fi
}
while true; do
now=$(date +%s)
if (( now - started > timeout_sec )); then
echo "FAIL reason=timeout sha=$sha" >&2
exit 1
fi
line=$(status_line)
set -- $line
kind=${1:-empty}
if [[ "$kind" == empty ]]; then
if (( now - empty_since > appear_sec )); then
echo "FAIL reason=no_pipeline sha=$sha origin=$forge_host/$forge_path" >&2
echo "This SHA has no CI pipeline. Tag or push a ref the project actually builds." >&2
exit 1
fi
sleep "$poll_sec"
continue
fi
if [[ "$kind" == error || "$kind" == auth ]]; then
echo "Error: unexpected forge response for $forge_path" >&2
exit 3
fi
pid=$1
st=$2
url=$3
empty_since=$now
if [[ "$first_seen" -eq 0 ]]; then
echo "Watching pipeline $pid status=$st $url" >&2
first_seen=1
fi
raw=$(query)
mapfile -t fields < <(printf '%s' "$raw" | parse_status)
st=${fields[0]:-error}
pid=${fields[1]:--}
url=${fields[2]:--}
jobs_s=${fields[3]:-}
case "$st" in
empty)
if (( now - empty_since >= appear_sec )); then
echo "FAIL reason=no_pipeline sha=$sha" >&2
echo "This SHA has no CI pipeline. Tag or push a ref the project actually builds." >&2
exit 1
fi
sleep "$poll_sec"
continue
;;
error)
echo "Error: unexpected CI status response" >&2
echo "$raw" | sed '/token/Id; /Bearer/d; /Authorization/Id' >&2
exit 3
;;
success)
echo "OK pipeline=$url sha=$sha id=$pid"
echo "OK pipeline=$url sha=$sha id=$pid jobs=$jobs_s"
exit 0
;;
failed|canceled|cancelled|skipped)
jobs_s=
if [[ "$forge_kind" != github ]]; then
jobs_s=$(gitlab_jobs "$pid" | summarize_jobs || true)
fi
echo "FAIL reason=job_$st sha=$sha pipeline=$url jobs=$jobs_s" >&2
failed|failure|canceled|cancelled|skipped)
echo "FAIL reason=job_${st} sha=$sha pipeline=$url jobs=$jobs_s" >&2
exit 1
;;
created|pending|running|waiting_for_resource|preparing|waiting|queued|in_progress|waiting_for_resource)
running|pending|queued|waiting|in_progress|created|preparing)
if [[ "$first_seen" -eq 0 ]]; then
echo "Watching pipeline $pid status=$st $url" >&2
first_seen=1
fi
empty_since=$now
sleep "$poll_sec"
;;
*)
empty_since=$now
sleep "$poll_sec"
;;
esac
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
# Query Gitea Actions for an exact SHA via curl. Output STATUS= keys.
# Auth: GITEA_TOKEN in the environment or .env.builder. GITEA_API_URL optional.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
read -r host path base <<<"$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^(https?)://([^/]+)(?:/(.*))?$", url)
scheme, host, path = (m.group(1), m.group(2), m.group(3) or "") if m else ("", "", "")
else:
m = re.match(r"^ssh://git@([^/]+)/(.+)$", url)
scheme, host, path = ("https", m.group(1), m.group(2)) if m else ("", "", "")
path = re.sub(r"\.git$", "", path)
print(host, path, (scheme or "https") + "://" + host)
')"
api=${GITEA_API_URL:-$base/api/v1}
api=${api%/}
[[ "$api" == */api/v1 ]] || api+=/api/v1
repo=$(WAIT_CI_PATH=$path python3 -c 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe="/"))')
gitea_curl() {
if [[ -n "${GITEA_TOKEN:-}" ]]; then
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 \
--config <(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN") "$1"
else
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 "$1"
fi
}
set +e
runs=$(gitea_curl "${api}/repos/${repo}/actions/runs?head_sha=${sha}&limit=20&page=1")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: curl cannot read Gitea Actions for $path" >&2
exit 3
fi
python3 -c '
import json, sys
sha = sys.argv[1]
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("STATUS=error")
raise SystemExit(0)
runs = data.get("workflow_runs", data.get("runs", [])) if isinstance(data, dict) else data
if not isinstance(runs, list) or not runs:
print("STATUS=empty")
raise SystemExit(0)
matched = []
for run in runs:
head = run.get("head_sha") or run.get("headSha") or (run.get("head_commit") or {}).get("id")
if head and head.lower() == sha.lower() and (run.get("id") or run.get("database_id")):
matched.append(run)
if not matched:
print("STATUS=empty")
raise SystemExit(0)
run = matched[0]
run_id = run.get("id") or run.get("database_id")
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
if conclusion in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out"):
state = "failed"
elif status in ("completed", "success") and conclusion in ("", "success", "succeeded"):
state = "success"
elif status == "completed" and conclusion:
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, run_id, url))
' "$sha" <<<"$runs"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Query GitHub Actions for an exact SHA via gh. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
repo=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
set +e
raw=$(gh run list --repo "$repo" --commit "$sha" --limit 20 --json databaseId,status,conclusion,url,headSha)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: gh cannot list runs for $repo" >&2
exit 3
fi
python3 -c '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if not data:
print("STATUS=empty")
raise SystemExit(0)
r = data[0]
status = str(r.get("status") or "").lower()
conc = str(r.get("conclusion") or "").lower()
if status == "completed" and conc == "success":
state = "success"
elif status == "completed":
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, r.get("databaseId") or "-", r.get("url") or "-"))
' <<<"$raw"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
# Query GitLab CI for an exact SHA via glab. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v glab >/dev/null 2>&1; then
echo "Error: glab is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
path=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
encoded=$(WAIT_CI_PATH=$path python3 -c 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))')
set +e
raw=$(glab api "projects/${encoded}/pipelines?sha=${sha}&per_page=20")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: glab cannot list pipelines for $path" >&2
exit 3
fi
python3 -c '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message"):
print("STATUS=error")
raise SystemExit(0)
if not isinstance(data, list) or not data:
print("STATUS=empty")
raise SystemExit(0)
p = data[0]
st = str(p.get("status") or "").lower()
if st in ("success", "passed"):
state = "success"
elif st in ("failed", "canceled", "cancelled", "skipped"):
state = "failed"
elif st in ("",):
state = "empty"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, p.get("id") or "-", p.get("web_url") or "-"))
' <<<"$raw"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Query Gitea Actions for an exact SHA via tea. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v tea >/dev/null 2>&1; then
echo "Error: tea is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
path=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
[[ -n "$path" ]] || { echo "Error: cannot parse origin" >&2; exit 3; }
set +e
runs=$(tea api "repos/${path}/actions/runs?head_sha=${sha}&limit=20")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: tea cannot read Actions for $path" >&2
exit 3
fi
python3 -c '
import json, sys
sha = sys.argv[1]
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("STATUS=error")
raise SystemExit(0)
runs = data.get("workflow_runs", data.get("runs", [])) if isinstance(data, dict) else data
if not isinstance(runs, list) or not runs:
print("STATUS=empty")
raise SystemExit(0)
matched = [run for run in runs if str(run.get("head_sha") or run.get("headSha") or "").lower() == sha.lower()]
if not matched:
print("STATUS=empty")
raise SystemExit(0)
run = matched[0]
run_id = run.get("id") or run.get("database_id") or "-"
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
if conclusion in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out"):
state = "failed"
elif status in ("completed", "success") and conclusion in ("", "success", "succeeded"):
state = "success"
elif status == "completed" and conclusion:
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, run_id, url))
' "$sha" <<<"$runs"
+2
View File
@@ -11,6 +11,8 @@
# DOCKER_REPOSITORY=
# IMAGE_TAG=
# PLATFORMS= # optional override; project ARCHES lives in makefile.builder
# GITEA_API_URL= # optional for the curl CI status script
# GITEA_TOKEN= # optional token for private Gitea repositories
# COPY_DEST= # fallback: local dir, or docker dest container:/absolute/dir
# COPY_SCRIPT= # external/temporary executable override; project-local paths belong in makefile.builder
# COPY_AS= # optional extra basenames for the first file
+9 -5
View File
@@ -8,9 +8,10 @@
ARCHES ?= amd64
ARCH ?= $(firstword $(ARCHES))
# local: make docker/deb on this machine. ci: obtain.sh waits for the
# forge pipeline of an exact SHA, then inspects the artifact. Not a track.
# local: make docker/deb on this machine. ci: obtain.sh polls
# CI_STATUS_SCRIPT for an exact SHA, then inspects the artifact. Not a track.
BUILD_VENUE ?= local
CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
@@ -31,12 +32,15 @@ DIST_DIR ?= dist
ifneq ($(BUILDER_SKILL_DIR),)
include $(BUILDER_SKILL_DIR)/scripts/version.mk
else
BUILDER_SCRIPTS := $(BUILDER_SKILL_DIR)/scripts
else ifneq ($(wildcard $(HOME)/.pouch/skills/builder/scripts/version.mk),)
include $(HOME)/.pouch/skills/builder/scripts/version.mk
BUILDER_SCRIPTS := $(HOME)/.pouch/skills/builder/scripts
else
include $(HOME)/.agents/skills/builder/scripts/version.mk
BUILDER_SCRIPTS := $(HOME)/.agents/skills/builder/scripts
endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
.PHONY: help version arches venue clean build deb docker obtain push push-deb push-docker
help:
+7 -1
View File
@@ -4,10 +4,16 @@
## 什么时候使用
通常由 `$bugfix-ship`、`$pr-ship` 或 `$requirement-flow` 内部读取,不需要单独调用。
通常由 `$short-ship`、`$pr-ship` 或 `$long-ship` 派发的 worker 读取,不需要单独调用。
入口主会话不要把本 skill 读进自己的上下文。
```text
按 delivery-loop 把当前 ship 推进到 review_ready。
```
## 使用前准备
- 入口已绑定 ship id 时,同时安装 `ship`,证据指针写进该记录。
- 项目已有有效的 ACK delivery profile 或等价交付配置。
- 已明确任务的目标分支、测试范围和允许的停止点。
- 需要外部写入时,当前请求已经授权对应动作。
+8 -5
View File
@@ -2,7 +2,7 @@
name: delivery-loop
description: >-
为修复、PR 和需求任务提供可配置的验证、发 PR、合并、回归、构建、发布与部署状态机。
仅作为 bugfix-ship、pr-ship、requirement-flow 的共享执行规则使用。
仅作为 short-ship、pr-ship、long-ship 的共享执行规则使用。
---
# Delivery Loop:共享交付状态机
@@ -11,9 +11,10 @@ description: >-
## 使用方式
只有在入口 skill 明确要求执行交付时才读取
`references/state-machine.md`。入口 skill 负责确定任务类型和目标;本 skill 负责阶段、
停止点、证据和安全门。
由 `short-ship` / `long-ship` / `pr-ship` **派出来的 worker** 读取本 skill 和
`references/state-machine.md`。入口主会话不要读取或执行本 skill。
入口决定任务类型、停止点和派哪一步;本 skill 负责该步的阶段要求、证据和安全门。
## 不可违反的规则
@@ -24,7 +25,9 @@ description: >-
- `stopAt` 未配置或与用户目标冲突时停止并报告缺口,不猜测是否应合并或部署。
- 提交、推送、合并、打 tag、发布产物和生产部署都必须同时满足项目配置与当前请求的授权。
- 不绕过审核、CI、测试失败、脏工作树、冲突、未确认的迁移或不可回滚的外部变更。
- 每个阶段都保存可复核证据;没有证据不得把阶段标为完成。
- 每个阶段都保存可复核证据;没有证据不得把阶段标为完成。入口已绑定 `ship-id` 时,把
指针写入 `.pouch/ship/<id>/current.yaml` 的 `evidence`(规则见 ship `record.md`);
缺指针则该阶段未完成。
- 失败时保留现场和回滚信息,不用重试掩盖失败,不伪造发布或部署成功。
## 结果
@@ -7,33 +7,43 @@ change_ready -> verified -> pr_opened -> merged -> regression_passed -> artifact
```
入口可以从已有状态开始,但不能跳过本次变更所需的验证。内部里程碑可以更细,但项目
ACK 配置必须使用以下 canonical `stopAt`:
Ship 配置使用以下 canonical `stopAt`:
- `verified`:只完成代码和验证。
- `validation_ready`:不可变产物已部署到开发/测试环境并通过健康检查,可交给用户验证。
- `review_ready`:PR/MR、预览产物和非生产验证证据已齐备,等待审核。
- `released`:经过本次 approval 后完成稳定发布或生产部署。
- `develop_verified`:开发阶段验证完成,包含单元测试、新增功能集成测试和静态检查;不等于完整回归。
- `review_ready`:PR/MR 已创建,等待审核。
- `release_ready`:PR 已合并且完整回归通过,映射内部 `regression_passed`;不表示已发布或部署。
- `released`:产物已构建并发布,映射内部 `published`。
- `deployed`:目标环境实际部署完成,映射内部 `deployed`。
旧记录可继续使用 `verified`(`develop_verified` 的别名)和 `validation_ready`
(`release_ready` 的别名);新记录应使用 canonical 名称。
入口 skill 的自然语言停止点映射如下:
| 用户目标 | ACK stopAt | 必要内部里程碑 |
| 用户目标 | Ship stopAt | 必要内部里程碑 |
| --- | --- | --- |
| 只验证 | `verified` | `verified` |
| 只做开发验证 | `develop_verified` | `verified` |
| 发 PR | `review_ready` | `pr_opened`,必要时预览构建/测试 |
| 部署测试环境 | `validation_ready` | `artifact_built`、`deployed`、健康检查 |
| 合并后回归但不发布 | `review_ready` 或任务级中间状态 | `merged`、`regression_passed` |
| 完整正式部署 | `released` | `merged`、`regression_passed`、`artifact_built`、`published`、`deployed` |
| 合并后完整回归 | `release_ready` | `merged`、`regression_passed` |
| 构建并发布产物 | `released` | `merged`、`regression_passed`、`artifact_built`、`published` |
| 实际部署 | `deployed` | `merged`、`regression_passed`、`artifact_built`、`published`、`deployed` |
## 每个阶段的要求
- `change_ready`:记录基线 commit、工作树、目标分支、任务范围和风险。
- `verified`:根因或需求验收明确;自动测试和必要的黑盒/真机验证通过。
- `verified`:开发阶段根因或需求验收明确;任务相关单元测试、新增功能集成测试和静态检查通过。
- `pr_opened`:提交说明、测试证据、风险和回滚信息已写入 PR;不能把草稿 PR 当 `review_ready`。
- `merged`:目标分支的合并 commit 已确认,不能只确认源分支 commit。
- `regression_passed`:以合并 commit 执行项目规定的完整回归;失败则回到修复或停止。
回归 worker 必须先发现项目及其子项目的 tester catalog(使用
`catalog.py roots --root <project-root>`),逐个测试根执行并记录独立快照;父仓库
没有 catalog 不能遮蔽有 catalog 的 `app` 子项目。任一发现的测试根未执行或无证据,
不得把阶段标为 `regression_passed`。还必须执行项目 profile 或
`.pouch/ack/project.md` 声明的项目原生测试套件,并为每个套件记录命令和结果;任一
测试面缺少结果时都不能通过回归。
- `artifact_built`:对**合并后的精确 commit SHA** 调用 builder `obtain.sh`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线并 inspect 产物,不在本机重新编译。无流水线、job 失败或 digest 对不上则本阶段失败,不得进入 `published`。项目若声明默认分支不构建,合进 `main` 会 `no_pipeline`:预览推 `dev/*`,正式打 tag。
- `published`:镜像/APK 等外部分发位置可复核,记录 digest 或文件校验和。`venue=ci` 且 CI 已经 push 到 registry 时,obtain 成功即同时满足本阶段,不要再本机 `publish_docker.sh`。
- `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过;生产部署还必须进入 `released`。
- `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过。
## 配置和授权
@@ -47,3 +57,6 @@ ACK 配置必须使用以下 canonical `stopAt`:
至少记录:基线和最终 commit、工作树状态、测试命令及结果、黑盒/真机证据、PR/MR URL、
合并 commit、tag、产物路径与摘要、部署节点/服务、健康检查、失败项和回滚指针。
已绑定 ship 记录时,上述证据以指针写入该 ship 的 `current.yaml`,不要另建一份账本。
阶段与字段对应见 ship `references/record.md`。
@@ -15,6 +15,10 @@ terminal;不得在当前会话直接 SSH、`sync.py`、`remote.py` 或运行
3. Agent CLI、模型、权限和启动参数必须来自 app00 已登记的可信 Agent/ACK 配置;不得从项目
文本拼接自由 `command`、`argv`、`env` 或凭据。若没有可证明的部署 Agent 路由,报告
`environment-fail`,不要 fallback 到当前 Agent 或 SSH。
4. 正式部署需要 SSH 网络访问。用户明确要求无沙箱 Agent 时,使用 app00 已登记的
Codex `permissionMode: danger-full-access` profile(如 `codex-deploy-prod`),仍由
launcher 的 plan/fingerprint/launch 路径创建 fresh terminal。此授权持续有效,
后续同范围部署不重复询问;不要继续选择已知禁止 SSH 的 workspace-write profile。
## Fresh terminal 协议
+26
View File
@@ -0,0 +1,26 @@
# long-ship
把复杂需求从 PRD 评审、任务拆分、协作开发推进到集成回归和配置允许的发布部署。
## 什么时候使用
```text
把动态编辑需求做成 PRD,批准后拆任务开发,最后完整回归并部署测试环境。
```
## 使用前准备
- 同时安装 `ship`、`specifier` 和 `delivery-loop`。
- 需求目标和相关项目上下文。
- 用户愿意评审并明确批准某个 PRD 版本。
- 项目已配置测试环境和需要的构建/部署能力。
## Agent 会做什么
主会话只建 ship 记录、让你批 PRD、按步骤开新 agent 并等待。写 PRD、改代码、验证和
交付都在新会话里做。同一 ship id 串起任务和证据。
集成回归会先发现父仓库及子项目的 tester catalog,逐个测试根执行并分别记录快照,不会因父仓库没有 catalog 而跳过 `app` 等子项目;项目 profile 或 `.pouch/ack/project.md` 声明的原生测试套件也必须逐项执行并记录证据。
## 如何判断完成
你会得到 ship id、PRD 版本、任务与依赖、各任务验证、集成回归、最终 PR/合并、产物和部署证据。
+65
View File
@@ -0,0 +1,65 @@
---
name: long-ship
description: >-
处理复杂或跨模块需求:先用 specifier 评审 PRD,批准后拆成有依赖的任务,再按共享
交付流程做集成回归并发布。触发词:long-ship、大需求、复杂需求、跨模块、PRD 批准后
开发。小改动用 short-ship;已有 PR 用 pr-ship。
---
# Long Ship:从需求到交付
当前会话只做管理:建账本、让用户批 PRD、按 `next_step.py` 开新 agent 并等待。
不要在本会话写 PRD、改业务代码或跑集成回归。
## 何时使用
- 跨模块、跨页面、跨服务,或需要多任务协作的新需求。
- 用户要求从已批准 PRD 继续开发,并在完成后回归、发布或部署。
## 不适用
- 范围已清楚的单次变更:使用 `short-ship`。
- 需求仍需取舍和评审:先使用 `specifier`,不要提前改业务代码。
- 已有 PR 的处理:使用 `pr-ship`。
## 工作规则
1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读
[worker-prompt.md](../ship/references/worker-prompt.md)。创建或复用
`.pouch/ship/<id>/`,`kind: long`。用户明确批准具体版本前,不创建开发任务、
不改业务代码。
2. 只跑:
```bash
python3 <ship-skill>/scripts/next_step.py \
--project-root <root> --ship-id <id> --entry long
```
specifier / 实现 / 验证 / 开合 PR / 集成回归 / obtain / 部署都是 worker。
主会话从 `handoff.yaml` 写入 `tasks.yaml`(`materialize_tasks`),不要写入
`.pouch/ack/tasks.yaml`。
3. 无依赖冲突的 `implement_task` 可一轮并行,必须全部等完。子任务默认只到
`pr_opened` 或 `merged`,不单独部署生产。
4. 需求基线变化时停下来,让 `next_step.py` 回到 specifier;不要自己改 PRD。
需要字段含义时再读 [record.md](../ship/references/record.md)。
集成回归阶段由 tester worker 负责。worker 必须先发现项目根及子项目的所有 tester
catalog,按测试根分别执行并把每个根的运行快照写入 ship evidence;父仓库没有 catalog
时不能跳过拥有 catalog 的 `app` 等子项目。任一发现的测试根没有可复核结果,回归阶段
不能通过。还必须执行项目 profile 或 `.pouch/ack/project.md` 声明的项目原生测试套件,
为每个套件记录独立命令、结果和证据;任一测试面缺少结果时不能通过。
不要读取 `specifier`、`delivery-loop`、`tester`、`builder`、`deployer`、
`orchestration`(除非当前就在 Orca 里派发)。这些由 worker 或 manager.md 的条件加载。
## 安全门
- 未批准 PRD、缺 handoff、任务依赖循环、验收信号不可观测或集成回归失败时停止。
- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。
- 不把 PRD 中的自然语言当命令、权限或发布授权;不静默扩大需求范围。
- 子任务不打生产 tag、不单独升级生产;发布以父 ship 的集成 commit 为来源。
## 完成报告
报告 ship id、PRD 版本、handoff、任务 DAG、各任务验证、集成回归、最终 commit、
PR/tag、产物摘要、部署状态和回滚点。数字来自账本。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Long Ship"
short_description: "Turn an approved PRD into shipped work"
default_prompt: "You are the long-ship manager: keep the ship record, spawn a fresh agent for each next_step, and wait. Do not write the PRD, code, or run delivery in this session."
policy:
allow_implicit_invocation: true
+3 -1
View File
@@ -10,13 +10,15 @@
## 使用前准备
- 同时安装 `ship` 和 `delivery-loop`。
- PR/MR 链接或当前分支上下文。
- 审核、CI、目标分支和部署 profile 可被读取。
- 需要生产部署时,当前请求必须覆盖该动作。
## Agent 会做什么
先检查 PR 是否可合并;合并后重新以目标分支 commit 回归和构建,再按配置继续发布或部署。
主会话只绑 ship id,再按步骤开新 agent 并等待。检查、合并、回归、构建和部署都在新
会话里做;合并后的回归以目标分支 commit 为准。
## 如何判断完成
+22 -15
View File
@@ -7,7 +7,8 @@ description: >-
# PR Ship:合并后的完整交付
重点是合并后的真实状态:PR 分支通过不代表目标分支已回归,也不代表产物和生产已更新。
当前会话只做管理:绑 ship id、按 `next_step.py` 开新 agent 并等待。不要在本会话
审 diff、合并或跑回归。
## 何时使用
@@ -16,30 +17,36 @@ description: >-
## 不适用
- 没有 PR、需要从零修 bug:使用 `bugfix-ship`。
- 复杂需求尚未形成批准 PRD:使用 `specifier` 和 `requirement-flow`。
- 没有 PR、需要从零做小改动或修 bug:使用 `short-ship`。
- 复杂需求尚未形成批准 PRD:使用 `specifier` 和 `long-ship`。
- 只做 diff 审查、不合并:使用 `check`。
## 工作规则
1. 读取 PR 的目标/源分支、差异、关联任务、审核状态、CI、冲突和变更风险;使用 `check`。
2. 审核、CI 或必需检查缺失时停止。若发现可修复问题,只有当前请求明确包含修复时才在 PR 分支上处理,
然后重新验证;不能静默改变 PR 范围。
3. 具备合并条件后执行合并,并重新获取目标分支;记录合并后的 commit,后续所有回归和构建都以它为来源。
4. 读取并执行 `delivery-loop` 的状态机,运行完整回归;失败时停在合并后的中间状态或回到修复流程,不部署失败版本。
5. 只有项目 profile 和当前授权允许时才构建、发布和部署;生产部署完成后做节点状态和关键路径检查。
1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读
[worker-prompt.md](../ship/references/worker-prompt.md)。从 PR 正文、分支名或
用户给出的 id 绑定已有 ship;没有则用 PR 标题生成 slug,创建 `kind: short` 的
薄记录,`source.kind: pr`。之后证据都写进这个 ship id。
2. 只跑:
```bash
python3 <ship-skill>/scripts/next_step.py \
--project-root <root> --ship-id <id> --entry pr
```
检查、修复、合并、回归、obtain、发布、部署都是 worker。主会话只写 `source.ref`
和等待结果。需要字段含义时再读 [record.md](../ship/references/record.md)。
3. 合并后的回归和构建必须以合并 commit 为来源;worker 会写 `commits.role: merged`。
不要读取 `check`、`delivery-loop`、`tester`、`builder`、`deployer`、`releaser`。
## 安全门
- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。
- 不绕过审核、CI、分支保护或冲突;不强推、不重写已共享历史。
- 不用 PR 源分支的旧 APK/镜像证明合并版本已发布。
- 目标分支变化、合并后代码漂移、回归失败、产物来源不明或部署健康检查失败时停止。
## 必读资料
- 执行交付阶段时读取已安装的 `delivery-loop` skill 及其状态机 reference。
- 发布版本时遵循 `releaser`;构建/部署/回归分别遵循 `builder`、`deployer`、`tester`。
## 完成报告
报告 PR/MR、审核/CI 结果、合并 commit、完整回归证据、产物摘要、tag、部署目标、健康检查和回滚点。
报告 ship id、PR/MR、审核/CI 结果、合并 commit、完整回归证据、产物摘要、tag、部署目标、健康检查和回滚点。数字来自账本。
+1 -1
View File
@@ -1,6 +1,6 @@
interface:
display_name: "PR Ship"
short_description: "Merge, regress, build, and deploy a PR"
default_prompt: "Use $pr-ship to process this PR through merge, full regression, and the configured deployment stop."
default_prompt: "You are the pr-ship manager: bind the ship id, spawn a fresh agent for each next_step, and wait. Do not review, merge, or regress in this session."
policy:
allow_implicit_invocation: true
+15 -14
View File
@@ -1,23 +1,24 @@
# requirement-flow
# requirement-flow(已弃用)
把复杂需求从 PRD 评审、任务拆分、协作开发一路推进到集成回归和配置允许的发布部署。
`requirement-flow` 已改名为 [long-ship](../long-ship/README.md)。本目录只是兼容占位,
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
`long-ship/`。
## 什么时候使用
## 使用示例
```text
把动态编辑需求做成 PRD,批准后拆任务开发,最后完整回归并部署测试环境。
使用 requirement-flow,把动态编辑需求做成 PRD,批准后拆任务开发。
```
## 使用前准备
Agent 命中后会立刻转到 `long-ship`,按同一套规则执行。新请求建议直接用新名。
- 需求目标和相关项目上下文。
- 用户愿意评审并明确批准某个 PRD 版本。
- 项目已配置 ACK、测试环境和需要的构建/部署能力。
## 迁移
## Agent 会做什么
```bash
pouch add long-ship # 新名,当前项目
pouch add long-ship -g # 新名,全局
```
先生成可评审 PRD;批准后建立有依赖的任务 DAG,独立验证子任务,最后以集成结果推进交付。
## 如何判断完成
你会得到 PRD 版本、任务与依赖、各任务验证、集成回归、最终 PR/合并、产物和部署证据。
- 旧软链保持有效,不需要先删除。
- 各项目 `.pouch.yaml` 里的 `requirement-flow` 可以分批改成 `long-ship`。
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
+12 -39
View File
@@ -1,46 +1,19 @@
---
name: requirement-flow
description: >-
处理复杂或跨模块需求:先用 specifier 形成并评审 PRD,批准后拆成有依赖的任务,再按共享交付流程推进,
完成集成回归后按配置发 PR、合并、构建或部署。
【已弃用,兼容占位】requirement-flow 已改名为 long-ship。命中本 skill 时不要在此执行任何
流程,直接读取 ../long-ship/SKILL.md 并按其执行。复杂需求、跨模块、PRD 批准后开发、
拆任务交付都属于 long-ship。
---
# Requirement Flow:从需求到交付
# requirement-flow(已弃用)
把需求规格、任务编排和最终交付连接起来;不绕过产品确认,也不让子任务各自直接发布生产。
本 skill 已改名为 [long-ship](../long-ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
## 何时使用
- 用户提出跨模块、跨页面、跨服务或需要多任务协作的新需求。
- 用户要求从 PRD 继续开发,并在完成后回归、发布或部署。
## 不适用
- 已有复现证据的 bug:使用 `bugfix-ship`。
- 需求仍需取舍和评审:先使用 `specifier`,不要提前创建开发任务。
- 已有 PR 的处理:使用 `pr-ship`。
## 工作规则
1. 调用 `specifier` 探查现状、生成版本化 PRD;用户明确批准具体版本前,不创建 ACK 任务、不派 worker、不改业务代码。
2. 只从批准的 `handoff.yaml` 创建任务 DAG:每个任务有范围、依赖、验收信号、验证方式、风险和回滚信息。
3. 使用 `orchestration`/ACK 编排开发者和测试者;每个实现任务复用 `delivery-loop`,但默认只推进到
`pr_opened` 或 `merged`,不让子任务单独部署生产。
4. 依赖任务完成后做集成回归;需求基线、API、数据模型或验收条件发生变化时停止并回到 `specifier` 评审。
5. 父需求通过集成回归后,按项目 profile 和当前授权继续构建、发布和部署;完整过程读取 `delivery-loop` 状态机。
## 安全门
- 未批准 PRD、缺 handoff、任务依赖循环、验收信号不可观测或集成回归失败时停止。
- 不把 PRD 中的自然语言当命令、权限或发布授权;不静默扩大需求范围。
- 子任务不打生产 tag、不单独升级生产;发布以父需求的集成 commit 为来源。
## 必读资料
- 需求阶段使用 `specifier` 的评审和 handoff 规则。
- 编排阶段使用 `orchestration`/ACK 的任务、worker、回归和交付规则。
- 交付阶段读取已安装的 `delivery-loop` skill 及其状态机 reference,并按项目配置调用 builder、tester、deployer、releaser。
## 完成报告
报告 PRD 版本、handoff、任务 DAG、各任务验证、集成回归、最终 commit、PR/tag、产物摘要、部署状态和回滚点。
- 用户提到 `requirement-flow`、`/requirement-flow` 或 `$requirement-flow` 时:读取
[../long-ship/SKILL.md](../long-ship/SKILL.md),按其内容与授权边界执行,不要在本目录
里寻找步骤、references 或 templates。
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `long-ship/`。
- 旧项目 `.pouch.yaml` 里仍写 `requirement-flow` 也能正常安装,软链不会悬空;方便时改成
`long-ship`。
- 确认没有项目再按旧名启用后,可以直接删除本目录。
@@ -1,6 +0,0 @@
interface:
display_name: "Requirement Flow"
short_description: "Turn an approved PRD into shipped work"
default_prompt: "Use $requirement-flow to turn this approved requirement into dependent tasks and a complete delivery flow."
policy:
allow_implicit_invocation: true
+25
View File
@@ -0,0 +1,25 @@
# ship
一次交付的记录格式:需求说明、任务、测试运行、产物和部署结果都挂在同一个 ship id 下。
通常由 `$short-ship`、`$long-ship` 或 `$pr-ship` 内部使用,不需要单独调用。
## 什么时候使用
```text
把这次改动记到 ship add-export-button,后续测试和部署都挂到这个 id。
```
## 使用前准备
- 项目是 Git 仓库。
- 入口 skill 已经决定这是 short 还是 long,或正在打开已有 ship。
## Agent 会做什么
在 `.pouch/ship/<ship-id>/` 建立或更新记录,把测试、产物和部署写成指针。三个入口的
主会话只编排,具体步骤开新 agent。
## 如何判断完成
目录能通过 ship 自带校验;用同一个 ship id 能找到 spec、任务和证据指针。
+48
View File
@@ -0,0 +1,48 @@
---
name: ship
description: >-
short-ship、long-ship、pr-ship、specifier 共用的交付记录格式。一次交付一个
ship-id,目录在 .pouch/ship/{id}/。新建 id 使用 YYYYMMDD-短语,例如
20261014-dynamic-editor。不单独作为用户入口;用户说 short-ship、
long-ship、交付记录、ship id 时由对应入口读取本 skill。
---
# Ship:一次交付的记录
这是内部记录契约,不替用户选择 short 或 long,也不执行构建或部署。
## 使用方式
`short-ship` / `long-ship` / `pr-ship` 的主会话只编排:读
[manager.md](references/manager.md),按 `scripts/next_step.py` 开新 agent;派发时
用 [worker-prompt.md](references/worker-prompt.md)。
创建目录用 `scripts/create_ship.py`,改证据指针用 `scripts/append_evidence.py`,
写回后跑 `scripts/validate_ship.py`。
## 不可违反的规则
- 进入 short-ship / long-ship / pr-ship 的第一件事是拿到或创建 `ship-id`。没有 id
不得改业务代码、不得跑 tester / obtain / deploy。
- 入口主会话不得加载 hunt / tester / builder / deployer / releaser / check /
specifier / delivery-loop,也不得亲自执行这些步骤;开不出新 agent 时停止。
- 记录只写 `.pouch/ship/<ship-id>/`。功能、小改动和本次修复的任务不要写入
`.pouch/ack/tasks.yaml`。ACK 任务板只收缺陷;缺陷要交付时在 ship 的 `source` 里引用。
- `ship-id` 用稳定短 slug:小写字母开头,小写字母、数字、连字符,最长 64。不要用日期、
随机数或当前分支名冒充业务 id。
- 短长同一套字段。`kind: short` 恰好 1 条 task;`kind: long` 是 DAG,子任务默认不是
新的 ship。
- tester / builder / deployer 的 SSOT 仍在各自目录。ship 只追加指针。本轮跑过的
测试、obtain、部署若没写入 `current.yaml` 的 `evidence`,该阶段不算完成。
- 新建 ship 的任务带 `testPlan`,开发 worker 把新增/修改测试写入
`2-develop/test-manifest.yaml`;verify 按 manifest 的 selector 执行,regress 再叠加
所有 tester catalog 根和项目原生完整测试。没有测试时必须有明确批准的 `testGap`。
- 项目构建/部署契约仍在 `.pouch/ack/delivery.yaml` 和 builder/deployer 配置里,不要
复制进每个 ship。
- 旧的 `.pouch/specifier/features/<id>/` 在继续该需求时迁到
`.pouch/ship/<id>/spec/`,规则见 [record.md](references/record.md)。
## 结果
有效 ship 目录含 `current.yaml`、`spec.md`、`tasks.yaml`、`status.md`;long 另有
`spec/` 下的 PRD 与 handoff。校验通过后入口 skill 才能继续交付。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Ship Record"
short_description: "Shared delivery record keyed by ship id"
default_prompt: "Use $ship only as the record format for an in-progress short-ship, long-ship, or pr-ship."
policy:
allow_implicit_invocation: false
+86
View File
@@ -0,0 +1,86 @@
# Ship 主会话:只编排
入口 `short-ship` / `long-ship` / `pr-ship` 的**当前会话是管理员**。它跟用户说话、
维护账本、按 `next_step.py` 开新 agent,然后等待结果。它不把具体步骤的 skill
读进自己的上下文,也不亲自做那些步骤。
## 目录
- [主会话可以做](#主会话可以做)
- [主会话不能做](#主会话不能做)
- [循环](#循环)
- [怎么开新 agent](#怎么开新-agent)
- [失败](#失败)
## 主会话可以做
- 跟用户确认范围、停止点、批准 PRD、授权生产部署,并写完成报告。
- 跑 ship 脚本:`new_ship_id.py`、`create_ship.py`、`next_step.py`、`append_evidence.py`(仅当 worker
已给出指针而漏写时)、`validate_ship.py`。
- 读写 `.pouch/ship/<id>/` 账本:`spec.md`、`tasks.yaml`、`current.yaml` 的
`status` / `delivery.state` / `spec.*` / `manager.*` / 任务 `status`。
- 按 `next_step.py` 的 JSON 开一个(或 long 的一轮并行 `implement_task`)新 agent,
把 [worker-prompt.md](worker-prompt.md) 填好交给它,**等到它返回**再继续。
需要看字段含义时读 [record.md](record.md)。不要预读其它 skill。
## 主会话不能做
- 不要读取或执行:`hunt`、`tester`、`builder`、`deployer`、`releaser`、`check`、
`specifier`、`delivery-loop`,以及它们的 `references/`。
- 不要改业务代码、跑测试、开/合 PR、obtain、发版、部署、写 HTML PRD。
- 不要在开不出新 agent 时改由自己做该步。
- 不要 fire-and-forget:派出去就回复“已派发”并结束本轮。
- 不要让 worker 再去调 `short-ship` / `long-ship` / `pr-ship`。
`next_step.py` 的 `actor` 为 `manager` 时,这一步就是跟用户说话或改账本,不是去加载
执行类 skill。
## 循环
1. 没有 `ship-id`:先用 `new_ship_id.py --phrase <短语>` 生成
`YYYYMMDD-短语`,再跑 `create_ship.py`,写 `spec.md` 四段(short)
或进入 long 的 specifier 步。功能任务不要写入 `.pouch/ack/tasks.yaml`。
2. 跑:
```bash
python3 <ship-skill>/scripts/next_step.py \
--project-root <root> --ship-id <id> --entry short|long|pr
```
3. 按 JSON 行动:
- `status: done`:写完成报告,结束。
- `status: blocked` / `ask_user`:告诉用户缺口或问题,等回复。生产部署(`stopAt:
deployed` 且下一步是 `deploy`)必须再问一次,除非当前这句话已经授权。
- `actor: manager`:补账本或问用户,再跑 `next_step.py`。
- `actor: worker`:开新 agent,等待,把结果写入 `manager.lastStep` /
`manager.lastResult` / `manager.lastSummary`,按 `targetState` / `taskId` 更新账本,立刻
`validate_ship.py`。失败不要自己补做。
4. 重复直到 `done`、`blocked` 且用户不再推进,或用户叫停。
不要凭记忆跳过 `next_step.py`。long 的无依赖 `implement_task` 可以一轮并行多个,
仍要全部等完再进入下一步。
## 怎么开新 agent
必须是 **fresh** 会话,prompt 只用 [worker-prompt.md](worker-prompt.md) 填好的那一份。
- 当前运行时有子 agent / Task / spawn:用它,不要把执行类 skill 先读进本会话。
- 当前就在 Orca 里:再读已安装的 `orchestration`,做 supervised dispatch(inject 后
`check --wait` 直到 `worker_done` 或 `escalation`)。不要在本文件复制 orca 命令。
- 没有开新 agent 的能力:`blocked`,说明缺什么。不要自己执行。
把 `next_step.py` 返回的 `stage` 字段填入 worker prompt 的 `<stage>`,让 worker 把摘要
写到对应的 `1-spec`、`2-develop` 等目录。worker 返回后把 summary/evidence 一并写入
`manager.lastSummary` 和阶段 `activity.md`。
一次只派 JSON 里的这一步(long 的并行 implement 除外)。worker 需要用户决定时,它
返回 `ask`;主会话问用户,再派或改账本。
## 失败
- worker `fail` / `blocked`:停在现场,报告摘要和证据。主会话不要接手做该步。
用户明确说重试时,再开一个**新** agent,不要复用刚失败的会话。
- 校验失败:先修账本再继续,不要假装阶段已完成。
- 范围涨到需要 PRD 或多条任务:同一 `ship-id` 把 `kind` 改为 `long`,之后 `--entry
long`。
+217
View File
@@ -0,0 +1,217 @@
# Ship 记录契约
## 目录
- [布局](#布局)
- [current.yaml](#currentyaml)
- [short 与 long](#short-与-long)
- [任务](#任务)
- [证据指针](#证据指针)
- [旧 specifier 目录](#旧-specifier-目录)
## 布局
```text
.pouch/ship/<ship-id>/
current.yaml # 总账:kind、状态、stopAt、指针
spec.md # 短需求说明(short 和 long 都有)
tasks.yaml # 本 ship 的任务 DAG
2-develop/test-manifest.yaml # 开发者新增/修改测试的机器清单
status.md # 给人看的一页摘要
1-spec/ # 规格、评审、根因和 handoff
2-develop/ # 开发任务和变更
3-test/ # 任务验证与集成回归
4-review/ # PR、审核与合并
5-build/ # 合并后的构建/obtain
6-publish/ # 发布产物
7-deploy/ # 部署与健康检查
stage.yaml # 阶段状态和包含的步骤
activity.md # 该阶段的调度、结果和证据摘要
spec/ # 仅 long:PRD 版本、评审、handoff
brief.md
prd-v001.html
review.yaml
decisions.yaml
risks.yaml
handoff.yaml # 仅 approved 后
```
`status.md` 给人看;Agent 以 `current.yaml` 为准。两份不一致时改 `status.md`。
阶段目录由 `create_ship.py` 初始化,由 `next_step.py` 在派发和收到结果时追加活动记录;
大日志留在原测试、构建或部署位置,阶段目录只保存摘要和路径。
新建 ship 使用可排序的 `YYYYMMDD-短语`,例如 `20261014-dynamic-editor`。旧的无日期
记录仍可读取和校验,不要为了改名移动既有 ship 目录。
## current.yaml
必填:`shipId`、`kind`、`status`、`title`、`spec`、`tasks`、`evidence`。
`shipId` 必须等于目录名。
```yaml
shipId: add-export-button
kind: short
title: 列表页增加导出按钮
status: in_progress
stopAt: review_ready
source:
kind: user
ref: null
parentShipId: null
childShipIds: []
spec:
path: spec.md
revision: null
specStatus: null
prd: null
approvedRevision: null
reviews: null
handoff: null
tasks:
path: tasks.yaml
delivery:
stopAt: review_ready
state: change_ready
manager:
lastStep: null
lastResult: null
inFlight: null
evidence:
testRuns: []
artifacts: []
deploys: []
prs: []
commits: []
```
`kind`:`short` | `long`。
`status`:`draft`、`spec_review`、`approved`、`in_progress`、`blocked`、`done`、`parked`、`superseded`。
`delivery.state` 使用 delivery-loop 内部里程碑:`change_ready`、`verified`、
`pr_opened`、`merged`、`regression_passed`、`artifact_built`、`published`、`deployed`。
用户可见的 canonical `stopAt` 与内部里程碑映射如下:
| stopAt | 停止含义 | 内部里程碑 |
| --- | --- | --- |
| `develop_verified` | 开发验证完成:单元测试、新增功能集成测试和静态检查通过;不等于完整回归 | `verified` |
| `review_ready` | PR/MR 已创建,等待审核 | `pr_opened` |
| `release_ready` | PR 已合并且完整回归通过;尚未表示发布或部署 | `regression_passed` |
| `released` | 产物已构建并发布 | `published` |
| `deployed` | 目标环境实际部署完成并通过健康检查 | `deployed` |
为兼容旧记录,`verified` 是 `develop_verified` 的别名,`validation_ready` 是
`release_ready` 的别名;新记录应使用 canonical 名称。`delivery.state` 内部里程碑
名称保持不变。
`source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时
`source.kind: ack` 且 `ref` 为缺陷任务 id。
`manager` 由入口主会话维护,可选。`lastStep` / `lastResult` / `lastSummary` 记录刚结束的那一步;
`inFlight` 在 worker 未返回时写成步骤名,返回后清掉。worker 不要写这些字段。
`lastResult`:`pass` | `fail` | `blocked` | `ask`。
## short 与 long
同一套字段,厚度不同。
| 字段 | short | long |
| --- | --- | --- |
| `spec.md` | 四段:要做什么、不做什么、可观察验收、怎么验。有故障证据时加复现和根因 | 同一四段作摘要 |
| `spec/` | 不要创建 | specifier 写入 PRD、评审、决策、风险、handoff |
| `spec.revision` 等 | 保持 `null` | 与 PRD 版本同步 |
| `tasks.yaml` | 恰好 1 条 | DAG;`dependsOn` 指向本文件内 id |
| 子项 | 无 | 默认是 task。只有该切片有独立 `stopAt` 并单独发布时才晋升为子 ship,写入 `childShipIds` |
范围变大时保持同一 `shipId`,把 `kind` 改成 `long`,补 `spec/` 并拆任务。
`spec.md` 不要写成 HTML PRD。long 的评审稿是 `spec/prd-vNNN.html`。
## 任务
`tasks.yaml` 只描述本 ship 的工作项,不是 ACK 缺陷板。
新建 ship 的任务默认带 `testPlan`。开发 worker 在 manifest 中记录测试的稳定 id、
runner、target、selector、覆盖的验收项和 `added|changed` 状态。`verify` 必须逐条执行
manifest;`regress` 在完整 tester catalog 和项目原生测试之外再次确认这些条目通过。
仅文档或明确无行为变化的任务可使用 `policy: not_applicable`,但必须填写被主会话接受的
`testGap.reason` 与 `acceptedBy`。
```yaml
shipId: add-export-button
tasks:
- id: T-01
title: 在列表页增加导出按钮
dependsOn: []
expected:
- 登录用户在列表页看到「导出」
verification:
- 浏览器打开列表页,按钮可见且可点
testPlan:
policy: required
manifest: 2-develop/test-manifest.yaml
status: open
stopAt: review_ready
```
允许的任务 `status`:`open`、`in_progress`、`blocked`、`verified`、`merged`、`done`。
long 的子任务默认 `stopAt` 为 `pr_opened` 或 `merged`,不要单独打生产 tag 或升级生产。
父 ship 在依赖任务完成后做集成回归,再按父记录的 `delivery.stopAt` 继续。
从 `spec/handoff.yaml` 建任务时复制 `id`、`title`、`dependsOn`、`expected`、
`verification`,不要写进 `.pouch/ack/tasks.yaml`。
## 证据指针
不要把 tester 快照、镜像层或 compose 日志复制进 ship 目录。追加指针:
```yaml
evidence:
testRuns:
- path: .pouch/tester/runs/20261013T031000Z-smoke-ab12.yaml
result: pass
suite: smoke
artifacts:
- sha: 0123456789abcdef0123456789abcdef01234567
digest: sha256:abc
ref: harbor.example.com/app:1.2.3
venue: ci
deploys:
- env: test
revision: 0123456789abcdef0123456789abcdef01234567
health: pass
prs:
- url: https://git.example.com/app/-/merge_requests/12
status: opened
commits:
- sha: 0123456789abcdef0123456789abcdef01234567
role: baseline
```
delivery-loop 阶段与指针:
| 阶段 | 至少追加 |
| --- | --- |
| `change_ready` | `commits` `role: baseline` |
| `verified` | `testRuns` |
| `pr_opened` | `prs` |
| `merged` | `commits` `role: merged` |
| `regression_passed` | `testRuns`(回归) |
| `artifact_built` / `published` | `artifacts` |
| `deployed` | `deploys` |
`path` 相对项目根。文件不存在则校验失败。用 `scripts/append_evidence.py` 追加后立刻
`validate_ship.py`。
## 旧 specifier 目录
打开某个 id 时按这个顺序:
1. `.pouch/ship/<id>/` 已存在:用它。若同时还留着
`.pouch/specifier/features/<id>/`,报告旧路径为遗留,不要两处同时写。
2. 只有 `.pouch/specifier/features/<id>/`:把它移到 `.pouch/ship/<id>/spec/`,按旧
`current.yaml` 生成 ship `current.yaml`(`kind: long`),用 `brief.md` 补
`spec.md` 摘要,再校验。
3. 两处都没有:由入口 skill 调用 `create_ship.py` 新建。
+60
View File
@@ -0,0 +1,60 @@
# Worker prompt 模板
主会话把下面整段交给**新的** agent,替换尖括号。不要追加 hunt / tester /
delivery-loop 的正文,不要让 worker 再开一层 ship 入口。
```text
你是 ship <ship-id> 的 worker,只做步骤 <step>。
项目根:<project-root>
Ship 目录:.pouch/ship/<ship-id>/
当前阶段目录:.pouch/ship/<ship-id>/<stage>/
任务 id:<taskId 或「无」>
目标 delivery.state:<targetState 或「不改」>
停止点 stopAt:<stopAt>
先读已安装的 skill:<skill 或「无,按 spec.md / 本任务改代码」>。
需要写证据指针时读 ship 的 references/record.md,用
scripts/append_evidence.py 追加后跑 scripts/validate_ship.py。
不要读取 short-ship、long-ship、pr-ship,不要再开 ship 入口,不要派下一个步骤。
范围:<goal>
验收:见 spec.md 与 tasks.yaml 里该任务的 expected / verification。
授权:<本步允许的 git/PR/部署动作;未写的不要做>
测试交接:`implement` / `implement_task` 必须在 ship 的 `2-develop/test-manifest.yaml`
登记本次新增或修改的测试。每条记录至少包含稳定 `id`、`kind`、`runner`、`target`、
`selector`、`covers` 和 `change: added|changed`。若确实没有行为变化或无法补测,必须
填写 `testGap.reason`,并由主会话明确写入 `acceptedBy`;不能留空后声称已验证。
测试边界:`verify` / `verify_task` 只负责开发阶段验证,执行任务相关的单元测试、
新增功能集成测试和静态检查,不执行合并后的完整 tester 回归。验证前先使用 ship skill
的 `scripts/validate_test_manifest.py <ship-dir>/2-develop/test-manifest.yaml`
带上当前 ship id、任务的 testPlan.policy 执行 `--mode verify`;逐条执行 manifest 中的
测试 selector,并在证据中记录测试 id。
`regress` 只在合并后负责完整回归,除 manifest 中的新增/修改测试外,必须覆盖发现的每个
tester catalog 根和项目声明的原生测试套件。
若步骤是 `verify_task` 或 `regress` 且 skill 是 `tester`:先执行
`python3 <tester-skill>/scripts/catalog.py roots --root <project-root>`。`verify_task` 按
任务范围选择相关测试根;`regress` 必须对返回的每个独立测试根分别执行 `validate`、
完整范围选择和测试。父仓库没有 catalog 时继续进入有 catalog 的子项目(例如 `app`),
不要把多个项目的 cases 合并。`regress` 只有在所有发现的测试根都完成、每个根都有自己
的 `.pouch/tester/runs/*.yaml`/`.csv` 快照并把所有路径写入 ship evidence 后,才可返回
pass;没有 HOST、设备或浏览器的测试根必须记录 environment-fail/blocked,不能把未执行
的根省略掉。同时执行项目 profile 或 `.pouch/ack/project.md` 声明的项目原生测试套件
(例如 `go test ./...`、构建检查或前端检查),每个套件都要有独立的命令、结果和证据;
不能只因为 tester catalog 已完成就跳过这些项目测试。
完成后只返回:
- step: <step>
- status: pass | fail | blocked | ask
- summary: 三句以内,做了什么、证据在哪、还缺什么
- evidence: 写入的指针
- ask: 若 status=ask,要主会话问用户的那一个问题
同时把本次工作的命令、关键结果、阻塞和证据路径写入当前阶段目录的
`activity.md`;不要把完整日志复制进 ship,只写可复核摘要和外部文件路径。
```
`<skill>` 必须用 `next_step.py` JSON 里的 `skill` 字段,不要改成别的。`skill` 为
`null` 时不要加载执行类 skill。
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Append one evidence pointer to current.yaml and re-validate."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
from ship_lib import dump_yaml, load_yaml
from validate_ship import validate_ship_dir
KINDS = ("testRun", "artifact", "deploy", "pr", "commit")
KEY = {
"testRun": "testRuns",
"artifact": "artifacts",
"deploy": "deploys",
"pr": "prs",
"commit": "commits",
}
def _entry(kind: str, args: argparse.Namespace) -> dict[str, Any]:
if kind == "testRun":
if not args.path or not args.result:
raise SystemExit("testRun 需要 --path 和 --result")
row: dict[str, Any] = {"path": args.path, "result": args.result}
if args.suite:
row["suite"] = args.suite
return row
if kind == "artifact":
if not args.sha:
raise SystemExit("artifact 需要 --sha")
row = {"sha": args.sha}
if args.digest:
row["digest"] = args.digest
if args.ref:
row["ref"] = args.ref
if args.venue:
row["venue"] = args.venue
return row
if kind == "deploy":
if not args.env:
raise SystemExit("deploy 需要 --env")
row = {"env": args.env}
if args.sha:
row["revision"] = args.sha
if args.health:
row["health"] = args.health
return row
if kind == "pr":
if not args.url:
raise SystemExit("pr 需要 --url")
row = {"url": args.url, "status": args.pr_status or "opened"}
return row
if kind == "commit":
if not args.sha or not args.role:
raise SystemExit("commit 需要 --sha 和 --role")
return {"sha": args.sha, "role": args.role}
raise SystemExit(f"未知 kind: {kind}")
def main() -> int:
parser = argparse.ArgumentParser(description="向 ship current.yaml 追加证据指针")
parser.add_argument("ship_dir")
parser.add_argument("--kind", required=True, choices=KINDS)
parser.add_argument("--path")
parser.add_argument("--result")
parser.add_argument("--suite")
parser.add_argument("--sha")
parser.add_argument("--digest")
parser.add_argument("--ref")
parser.add_argument("--venue")
parser.add_argument("--env")
parser.add_argument("--health")
parser.add_argument("--url")
parser.add_argument("--pr-status")
parser.add_argument("--role")
args = parser.parse_args()
ship_dir = Path(args.ship_dir).resolve()
current_path = ship_dir / "current.yaml"
data = load_yaml(current_path)
if not isinstance(data, dict):
raise SystemExit("current.yaml 不是 mapping")
evidence = data.setdefault("evidence", {})
if not isinstance(evidence, dict):
raise SystemExit("evidence 不是 mapping")
key = KEY[args.kind]
bucket = evidence.setdefault(key, [])
if not isinstance(bucket, list):
raise SystemExit(f"evidence.{key} 不是列表")
bucket.append(_entry(args.kind, args))
dump_yaml(current_path, data)
issues = validate_ship_dir(ship_dir)
if issues:
for item in issues:
print(f"✗ {item}", file=sys.stderr)
return 1
print(f"✓ 已追加 evidence.{key}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Create a .pouch/ship/<id>/ record from templates. Refuse to overwrite."""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
from ship_lib import check_ship_id, ensure_stage_layout, skill_dir
from validate_ship import validate_ship_dir
def _replace(text: str, mapping: dict[str, str]) -> str:
for key, value in mapping.items():
text = text.replace(f"<{key}>", value)
text = text.replace(f"{{{{ {key} }}}}", value)
return text
def create_ship(
project_root: Path,
ship_id: str,
kind: str,
title: str,
*,
stop_at: str | None = None,
source_kind: str = "user",
source_ref: str | None = None,
) -> Path:
err = check_ship_id(ship_id)
if err:
raise SystemExit(err)
if kind not in {"short", "long"}:
raise SystemExit(f"kind 无效: {kind}")
title = title.strip()
if not title:
raise SystemExit("title 不能为空")
dest = project_root / ".pouch" / "ship" / ship_id
if dest.exists():
raise SystemExit(f"拒绝覆盖已有路径: {dest}")
templates = skill_dir() / "templates"
dest.mkdir(parents=True)
mapping = {
"ship-id": ship_id,
"title": title,
"kind": kind,
"short title": title,
}
current = _replace(
(templates / "current.template.yaml").read_text(encoding="utf-8"),
mapping,
)
current = current.replace("kind: short", f"kind: {kind}", 1)
current = current.replace("status: draft", "status: draft", 1)
if stop_at:
current = current.replace("stopAt: null", f"stopAt: {stop_at}")
if source_kind != "user":
current = current.replace("kind: user", f"kind: {source_kind}", 1)
if source_ref:
current = current.replace("ref: null", f"ref: {source_ref}", 1)
if kind == "long":
current = current.replace("status: draft", "status: spec_review", 1)
(dest / "current.yaml").write_text(current, encoding="utf-8")
(dest / "spec.md").write_text(
_replace((templates / "spec.template.md").read_text(encoding="utf-8"), mapping),
encoding="utf-8",
)
(dest / "tasks.yaml").write_text(
_replace(
(templates / "tasks.template.yaml").read_text(encoding="utf-8"), mapping
),
encoding="utf-8",
)
(dest / "status.md").write_text(
_replace(
(templates / "status.template.md").read_text(encoding="utf-8"),
{**mapping, "status": "spec_review" if kind == "long" else "draft",
"stopAt": stop_at or "null", "delivery.state": "change_ready"},
),
encoding="utf-8",
)
if kind == "long":
spec_dir = dest / "spec"
spec_dir.mkdir()
(spec_dir / "brief.md").write_text(f"# {title}\n\n", encoding="utf-8")
(spec_dir / "review.yaml").write_text("[]\n", encoding="utf-8")
(spec_dir / "decisions.yaml").write_text("[]\n", encoding="utf-8")
(spec_dir / "risks.yaml").write_text("[]\n", encoding="utf-8")
ensure_stage_layout(dest)
manifest = _replace(
(templates / "test-manifest.template.yaml").read_text(encoding="utf-8"),
mapping,
)
(dest / "2-develop" / "test-manifest.yaml").write_text(
manifest, encoding="utf-8"
)
issues = validate_ship_dir(dest)
if issues:
shutil.rmtree(dest)
for item in issues:
print(f"✗ {item}", file=sys.stderr)
raise SystemExit("新建后校验失败,已删除不完整目录")
return dest
def main() -> int:
parser = argparse.ArgumentParser(description="新建 ship 记录")
parser.add_argument("--project-root", required=True)
parser.add_argument("--ship-id", required=True)
parser.add_argument("--kind", choices=("short", "long"), required=True)
parser.add_argument("--title", required=True)
parser.add_argument("--stop-at", default=None)
parser.add_argument("--source-kind", default="user")
parser.add_argument("--source-ref", default=None)
args = parser.parse_args()
dest = create_ship(
Path(args.project_root).resolve(),
args.ship_id,
args.kind,
args.title,
stop_at=args.stop_at,
source_kind=args.source_kind,
source_ref=args.source_ref,
)
print(dest)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Print a sortable YYYYMMDD-short-phrase ship id."""
from __future__ import annotations
import argparse
from datetime import date, datetime
from ship_lib import dated_ship_id
def main() -> int:
parser = argparse.ArgumentParser(description="生成可排序的 ship-id")
parser.add_argument("--phrase", required=True, help="小写英文短语,例如 dynamic-editor")
parser.add_argument("--date", default=None, help="可选日期,格式 YYYYMMDD")
args = parser.parse_args()
selected = date.today()
if args.date:
selected = datetime.strptime(args.date, "%Y%m%d").date()
print(dated_ship_id(args.phrase, today=selected))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+672
View File
@@ -0,0 +1,672 @@
#!/usr/bin/env python3
"""Print the next manager/worker step for a ship as JSON."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
from ship_lib import (
STOP_AT_STATE,
ensure_stage_layout,
load_yaml,
record_stage_event,
stage_for_step,
)
SPEC_PLACEHOLDERS = (
"<用户可见的目标",
"<明确排除的范围",
"<能被浏览器",
"<命令、页面路径",
)
TASK_PLACEHOLDERS = (
"<observable acceptance",
"<how to verify",
"<short title>",
)
FAULT_PLACEHOLDER = "<没有故障时删除"
DONE_TASK = frozenset({"verified", "merged", "done"})
READY_DEP = frozenset({"verified", "merged", "done"})
DELIVERY_STEPS: tuple[tuple[str, str, str], ...] = (
("verified", "verify", "tester"),
("pr_opened", "open_pr", "delivery-loop"),
("merged", "merge_pr", "delivery-loop"),
("regression_passed", "regress", "tester"),
("artifact_built", "obtain", "builder"),
("published", "publish", "releaser"),
("deployed", "deploy", "deployer"),
)
DELIVERY_ORDER = ("change_ready",) + tuple(s[0] for s in DELIVERY_STEPS)
def _out(
*,
ship_id: str,
entry: str,
actor: str,
step: str,
status: str = "ready",
skill: str | None = None,
task_id: str | None = None,
target_state: str | None = None,
stop_at: str | None = None,
ready_task_ids: list[str] | None = None,
summary: str,
) -> dict[str, Any]:
return {
"shipId": ship_id,
"entry": entry,
"actor": actor,
"step": step,
"status": status,
"skill": skill,
"taskId": task_id,
"targetState": target_state,
"stopAt": stop_at,
"readyTaskIds": ready_task_ids or [],
"summary": summary,
"stage": (stage_for_step(step) or (None, None))[0],
}
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8") if path.is_file() else ""
def _has_any(text: str, markers: tuple[str, ...]) -> bool:
return any(m in text for m in markers)
def _fault_section(spec_md: str) -> str:
match = re.search(r"^## 故障证据\s*$", spec_md, re.MULTILINE)
if not match:
return ""
rest = spec_md[match.end() :]
nxt = re.search(r"^## ", rest, re.MULTILINE)
body = rest[: nxt.start()] if nxt else rest
return body.strip()
def _needs_hunt(spec_md: str) -> bool:
body = _fault_section(spec_md)
if not body or FAULT_PLACEHOLDER in body:
return False
return True
def _tasks(ship_dir: Path) -> list[dict[str, Any]]:
data = load_yaml(ship_dir / "tasks.yaml")
if not isinstance(data, dict):
return []
tasks = data.get("tasks")
return [t for t in tasks if isinstance(t, dict)] if isinstance(tasks, list) else []
def _task_placeholders(tasks: list[dict[str, Any]]) -> bool:
blob = json.dumps(tasks, ensure_ascii=False)
return _has_any(blob, TASK_PLACEHOLDERS)
def _commits(current: dict[str, Any]) -> list[dict[str, Any]]:
evidence = current.get("evidence") or {}
if not isinstance(evidence, dict):
return []
rows = evidence.get("commits") or []
return [r for r in rows if isinstance(r, dict)]
def _has_change_commit(current: dict[str, Any]) -> bool:
return any(r.get("role") in {"head", "merged"} for r in _commits(current))
def _has_merged_commit(current: dict[str, Any]) -> bool:
return any(r.get("role") == "merged" for r in _commits(current))
def _manager(current: dict[str, Any]) -> dict[str, Any]:
value = current.get("manager")
return value if isinstance(value, dict) else {}
def _stop_state(stop_at: str | None) -> str | None:
if not stop_at:
return None
return STOP_AT_STATE.get(stop_at)
def _state_index(state: str | None) -> int:
if state in DELIVERY_ORDER:
return DELIVERY_ORDER.index(state)
return 0
def _next_delivery(
*,
ship_id: str,
entry: str,
current_state: str,
stop_at: str | None,
skip_until: str | None = None,
) -> dict[str, Any] | None:
target_stop = _stop_state(stop_at)
if not stop_at or not target_stop:
return _out(
ship_id=ship_id,
entry=entry,
actor="manager",
step="ask_user",
status="ask_user",
stop_at=stop_at,
summary="stopAt 未配置或无法映射,主会话向用户确认停止点后再继续",
)
start = _state_index(current_state)
floor = _state_index(skip_until) if skip_until else start
start = max(start, floor)
stop_i = _state_index(target_stop)
if start >= stop_i:
return None
nxt = DELIVERY_STEPS[start]
state, step, skill = nxt
summary = f"派 worker 把 delivery.state 推进到 {state},加载 {skill}"
if step == "verify":
summary = (
"派 tester worker 做开发阶段验证:执行单元测试、新增功能集成测试和静态检查;"
"不执行合并后的完整 tester 回归"
)
if step == "regress":
summary = (
"派 tester worker 做合并后的完整回归:先发现父仓库和子项目的所有 tester "
"catalog,逐根执行并为每根写独立快照;再执行项目声明的原生测试套件;"
"任一测试面缺证据不得通过"
)
return _out(
ship_id=ship_id,
entry=entry,
actor="worker",
step=step,
skill=skill,
target_state=state,
stop_at=stop_at,
summary=summary,
)
def _failed(ship_id: str, entry: str, mgr: dict[str, Any], stop_at: str | None) -> dict[str, Any] | None:
if mgr.get("lastResult") not in {"fail", "blocked"}:
return None
last = str(mgr.get("lastStep") or "unknown")
return _out(
ship_id=ship_id,
entry=entry,
actor="manager",
step=last,
status="blocked",
stop_at=stop_at,
summary=f"上一步 {last} 失败或阻塞,主会话不要自己做;问用户是否开新 agent 重试",
)
def _ready_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_id = {str(t.get("id")): t for t in tasks if t.get("id")}
ready: list[dict[str, Any]] = []
for task in tasks:
status = task.get("status")
if status not in {"open", "in_progress"}:
continue
deps = task.get("dependsOn") or []
if not isinstance(deps, list):
continue
ok = True
for dep in deps:
other = by_id.get(str(dep))
if other is None or other.get("status") not in READY_DEP:
ok = False
break
if ok:
ready.append(task)
return ready
def decide_next(ship_dir: Path, entry: str) -> dict[str, Any]:
current_path = ship_dir / "current.yaml"
if not current_path.is_file():
return _out(
ship_id=ship_dir.name,
entry=entry,
actor="manager",
step="ensure_record",
status="blocked",
summary="没有 current.yaml,主会话先跑 create_ship.py",
)
current = load_yaml(current_path)
if not isinstance(current, dict):
return _out(
ship_id=ship_dir.name,
entry=entry,
actor="manager",
step="ensure_record",
status="blocked",
summary="current.yaml 无法解析",
)
ship_id = str(current.get("shipId") or ship_dir.name)
kind = current.get("kind")
stop_at = current.get("stopAt") or (current.get("delivery") or {}).get("stopAt")
if isinstance(stop_at, str) and stop_at in {"", "null"}:
stop_at = None
state = (current.get("delivery") or {}).get("state") or "change_ready"
source = current.get("source") if isinstance(current.get("source"), dict) else {}
spec = current.get("spec") if isinstance(current.get("spec"), dict) else {}
mgr = _manager(current)
spec_md = _text(ship_dir / "spec.md")
tasks = _tasks(ship_dir)
stop = stop_at if isinstance(stop_at, str) else None
failed = _failed(ship_id, entry, mgr, stop)
if failed:
return failed
if mgr.get("lastResult") == "ask":
last = str(mgr.get("lastStep") or "unknown")
return _out(
ship_id=ship_id,
entry=entry,
actor="manager",
step="ask_user",
status="ask_user",
stop_at=stop,
summary=f"上一步 {last} 需要用户决定,主会话先问用户,不要自己执行下一步",
)
in_flight = mgr.get("inFlight")
if isinstance(in_flight, str) and in_flight.strip():
return _out(
ship_id=ship_id,
entry=entry,
actor="manager",
step=in_flight,
status="blocked",
stop_at=stop,
summary=f"manager.inFlight={in_flight},先等待该 worker,不要重派或自己做",
)
use_pr = entry == "pr" or source.get("kind") == "pr"
use_long = (not use_pr) and (entry == "long" or kind == "long")
if use_pr:
return _decide_pr(ship_id, current, source, mgr, state, stop)
if use_long:
return _decide_long(ship_dir, ship_id, current, spec, spec_md, tasks, state, stop)
return _decide_short(ship_id, spec_md, tasks, current, state, stop)
def _decide_short(
ship_id: str,
spec_md: str,
tasks: list[dict[str, Any]],
current: dict[str, Any],
state: str,
stop_at: str | None,
) -> dict[str, Any]:
if not spec_md or _has_any(spec_md, SPEC_PLACEHOLDERS) or _task_placeholders(tasks):
return _out(
ship_id=ship_id,
entry="short",
actor="manager",
step="write_spec",
stop_at=stop_at,
summary="主会话补齐 spec.md 四段和唯一任务的 expected/verification,不要加载执行类 skill",
)
if not stop_at:
return _out(
ship_id=ship_id,
entry="short",
actor="manager",
step="ask_user",
status="ask_user",
summary=(
"stopAt 未配置,主会话向用户确认 develop_verified / review_ready / "
"release_ready / released / deployed"
),
)
if state == "change_ready" and _needs_hunt(spec_md) and not _has_change_commit(current):
last = _manager(current).get("lastStep")
if last != "hunt":
return _out(
ship_id=ship_id,
entry="short",
actor="worker",
step="hunt",
skill="hunt",
stop_at=stop_at,
summary="派 hunt worker 确认根因、复现和验收信号",
)
if state == "change_ready" and not _has_change_commit(current):
return _out(
ship_id=ship_id,
entry="short",
actor="worker",
step="implement",
skill=None,
task_id=str(tasks[0]["id"]) if tasks and tasks[0].get("id") else "T-01",
stop_at=stop_at,
summary="派 worker 按 spec.md 改代码,不要加载 delivery-loop",
)
nxt = _next_delivery(
ship_id=ship_id, entry="short", current_state=state, stop_at=stop_at
)
if nxt:
return nxt
return _out(
ship_id=ship_id,
entry="short",
actor="manager",
step="report",
status="done",
stop_at=stop_at,
summary="已到停止点,主会话按账本写完成报告",
)
def _decide_long(
ship_dir: Path,
ship_id: str,
current: dict[str, Any],
spec: dict[str, Any],
spec_md: str,
tasks: list[dict[str, Any]],
state: str,
stop_at: str | None,
) -> dict[str, Any]:
spec_status = spec.get("specStatus")
handoff = spec.get("handoff")
prd = spec.get("prd")
handoff_path = ship_dir / str(handoff) if handoff else ship_dir / "spec" / "handoff.yaml"
if spec_status not in {"approved"}:
if spec_status in {None, "", "draft"} and not prd:
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="specifier_new",
skill="specifier",
stop_at=stop_at,
summary="派 specifier worker 写 spec/ 下的 PRD,主会话不要读 specifier",
)
if spec_status in {"changes_requested"}:
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="specifier_review",
skill="specifier",
stop_at=stop_at,
summary="派 specifier worker 按用户意见出新 PRD 版本",
)
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="ask_user",
status="ask_user",
stop_at=stop_at,
summary="PRD 待用户批准或提出修改;主会话问用户,不要自己改 PRD",
)
if not handoff_path.is_file():
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="specifier_handoff",
skill="specifier",
stop_at=stop_at,
summary="已批准但缺少 handoff,派 specifier worker 生成 spec/handoff.yaml",
)
if _task_placeholders(tasks):
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="materialize_tasks",
stop_at=stop_at,
summary="主会话从 handoff.yaml 写入 tasks.yaml,不要加载 specifier",
)
unfinished = [t for t in tasks if t.get("status") not in DONE_TASK]
if unfinished:
ready = _ready_tasks(tasks)
if not ready:
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="ask_user",
status="blocked",
stop_at=stop_at,
summary="有未完成任务但依赖未就绪,主会话报告阻塞,不要自己开发",
)
open_ready = [t for t in ready if t.get("status") == "open"]
if open_ready:
ids = [str(t["id"]) for t in open_ready if t.get("id")]
first = ids[0]
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="implement_task",
skill=None,
task_id=first,
ready_task_ids=ids,
stop_at=stop_at,
summary="派 worker 实现无依赖冲突的任务;多个 id 可并行,仍全部等完",
)
inprog = [t for t in ready if t.get("status") == "in_progress"]
task = inprog[0]
tid = str(task.get("id") or "")
last = _manager(current).get("lastStep")
if last == "implement_task":
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="verify_task",
skill="tester",
task_id=tid,
target_state="verified",
stop_at=stop_at,
summary="派 tester worker 验证该任务,主会话不要自己点浏览器",
)
task_stop = task.get("stopAt") or "merged"
if last == "verify_task" and task_stop in {"pr_opened", "merged", "review_ready"}:
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="open_pr",
skill="delivery-loop",
task_id=tid,
target_state="pr_opened",
stop_at=stop_at,
summary="派 worker 为该子任务开 PR,默认不部署生产",
)
if last == "open_pr" and task_stop in {"merged"}:
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="merge_pr",
skill="delivery-loop",
task_id=tid,
target_state="merged",
stop_at=stop_at,
summary="派 worker 合并该子任务 PR,不要升级生产",
)
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="ask_user",
status="blocked",
task_id=tid,
stop_at=stop_at,
summary=f"任务 {tid} 处于 in_progress,但 lastStep={last!r},主会话先核对账本",
)
parent_state = state
if _state_index(state) < _state_index("merged"):
parent_state = "merged"
nxt = _next_delivery(
ship_id=ship_id,
entry="long",
current_state=parent_state,
stop_at=stop_at,
skip_until="merged",
)
if nxt:
return nxt
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="report",
status="done",
stop_at=stop_at,
summary="已到停止点,主会话按账本写完成报告",
)
def _decide_pr(
ship_id: str,
current: dict[str, Any],
source: dict[str, Any],
mgr: dict[str, Any],
state: str,
stop_at: str | None,
) -> dict[str, Any]:
if not source.get("ref"):
return _out(
ship_id=ship_id,
entry="pr",
actor="manager",
step="bind_pr",
stop_at=stop_at,
summary="主会话把 PR/MR 写入 source.ref 和 evidence.prs,不要加载 check",
)
if not stop_at:
return _out(
ship_id=ship_id,
entry="pr",
actor="manager",
step="ask_user",
status="ask_user",
summary="stopAt 未配置,主会话向用户确认合并后停在哪一档",
)
if not _has_merged_commit(current) and state not in {
"merged",
"regression_passed",
"artifact_built",
"published",
"deployed",
}:
if mgr.get("lastStep") == "check_pr" and mgr.get("lastResult") == "pass":
return _out(
ship_id=ship_id,
entry="pr",
actor="worker",
step="merge_pr",
skill="delivery-loop",
target_state="merged",
stop_at=stop_at,
summary="派 worker 合并 PR,并以合并 commit 作为后续来源",
)
if mgr.get("lastStep") == "check_pr" and mgr.get("lastResult") == "ask":
return _out(
ship_id=ship_id,
entry="pr",
actor="worker",
step="fix_pr",
skill=None,
stop_at=stop_at,
summary="检查要求修复且当前请求含修复时,派 worker 在 PR 分支上改;不要扩范围",
)
return _out(
ship_id=ship_id,
entry="pr",
actor="worker",
step="check_pr",
skill="check",
stop_at=stop_at,
summary="派 check worker 看差异、审核和 CI,主会话不要自己审代码",
)
nxt = _next_delivery(
ship_id=ship_id,
entry="pr",
current_state=state if state != "change_ready" else "merged",
stop_at=stop_at,
skip_until="merged",
)
if nxt:
return nxt
return _out(
ship_id=ship_id,
entry="pr",
actor="manager",
step="report",
status="done",
stop_at=stop_at,
summary="已到停止点,主会话按账本写完成报告",
)
def main() -> int:
parser = argparse.ArgumentParser(description="计算 ship 的下一步")
parser.add_argument("--project-root", required=True)
parser.add_argument("--ship-id", required=True)
parser.add_argument("--entry", choices=("short", "long", "pr"), required=True)
args = parser.parse_args()
ship_dir = Path(args.project_root).resolve() / ".pouch" / "ship" / args.ship_id
if not ship_dir.is_dir():
doc = _out(
ship_id=args.ship_id,
entry=args.entry,
actor="manager",
step="ensure_record",
status="blocked",
summary="没有 ship 目录,主会话先跑 create_ship.py",
)
json.dump(doc, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
current = load_yaml(ship_dir / "current.yaml")
if isinstance(current, dict):
manager = current.get("manager")
if isinstance(manager, dict) and manager.get("lastStep") and manager.get("lastResult"):
record_stage_event(
ship_dir,
step=str(manager.get("lastStep")),
status=str(manager.get("lastResult")),
summary=str(manager.get("lastSummary") or ""),
)
doc = decide_next(ship_dir, args.entry)
ensure_stage_layout(ship_dir)
record_stage_event(
ship_dir,
step=str(doc.get("step") or ""),
status=str(doc.get("status") or "ready"),
summary=str(doc.get("summary") or ""),
task_id=doc.get("taskId"),
)
json.dump(doc, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Shared ship-id and YAML helpers. Zero third-party deps."""
from __future__ import annotations
import re
import sys
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from pouch.yaml_io import safe_dump, safe_load # noqa: E402
SHIP_ID_RE = re.compile(
r"^(?:[a-z][a-z0-9]*(?:-[a-z0-9]+)*|\d{8}-[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$"
)
DATED_SHIP_ID_RE = re.compile(r"^\d{8}-[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
DATE_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$|^\d{8}$")
KIND = frozenset({"short", "long"})
SHIP_STATUS = frozenset(
{
"draft",
"spec_review",
"approved",
"in_progress",
"blocked",
"done",
"parked",
"superseded",
}
)
TASK_STATUS = frozenset(
{"open", "in_progress", "blocked", "verified", "merged", "done"}
)
DELIVERY_STATE = frozenset(
{
"change_ready",
"verified",
"pr_opened",
"merged",
"regression_passed",
"artifact_built",
"published",
"deployed",
}
)
# User-facing stop points. The two legacy names remain accepted so existing
# ship records continue to resolve to the new phase meanings.
STOP_AT_STATE = {
"develop_verified": "verified",
"review_ready": "pr_opened",
"release_ready": "regression_passed",
"released": "published",
"deployed": "deployed",
"verified": "verified", # legacy alias for develop_verified
"validation_ready": "regression_passed", # legacy alias for release_ready
}
STOP_AT = frozenset(STOP_AT_STATE)
SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"})
COMMIT_ROLE = frozenset({"baseline", "merged", "head"})
# These directories are the human-readable audit trail for the orchestration
# phases. The step list is intentionally kept here so create_ship and
# next_step cannot drift apart.
STAGE_DEFINITIONS = (
("1-spec", "规格与审批", ("write_spec", "specifier_new", "specifier_review", "specifier_handoff", "materialize_tasks", "hunt")),
("2-develop", "开发", ("implement", "implement_task",)),
("3-test", "验证与回归", ("verify", "verify_task", "regress")),
("4-review", "PR 与合并", ("open_pr", "check_pr", "fix_pr", "merge_pr")),
("5-build", "构建", ("obtain",)),
("6-publish", "发布", ("publish",)),
("7-deploy", "部署", ("deploy",)),
)
STAGE_BY_STEP = {
step: (directory, title)
for directory, title, steps in STAGE_DEFINITIONS
for step in steps
}
def skill_dir() -> Path:
return Path(__file__).resolve().parents[1]
def load_yaml(path: Path) -> Any:
return safe_load(path.read_text(encoding="utf-8"))
def dump_yaml(path: Path, data: Any) -> None:
path.write_text(safe_dump(data), encoding="utf-8")
def check_ship_id(ship_id: str) -> str | None:
if not ship_id:
return "shipId 为空"
if len(ship_id) > 64:
return f"shipId 超过 64 字符: {ship_id}"
if DATE_ID_RE.fullmatch(ship_id):
return f"shipId 不能是日期: {ship_id}"
if not SHIP_ID_RE.fullmatch(ship_id):
return (
f"shipId 无效: {ship_id!r}(小写字母开头,仅小写字母、数字、连字符)"
)
return None
def check_dated_ship_id(ship_id: str) -> str | None:
"""Validate the convention for newly-created records.
check_ship_id remains backward compatible so existing ship records can be
inspected. New entry points should use this stricter check.
"""
err = check_ship_id(ship_id)
if err:
return err
if not DATED_SHIP_ID_RE.fullmatch(ship_id):
return (
f"新 ship-id 必须是 YYYYMMDD-短语格式: {ship_id!r}"
)
return None
def dated_ship_id(phrase: str, *, today: date | None = None) -> str:
"""Build a sortable ship id from a short lowercase phrase."""
phrase = re.sub(r"[^a-z0-9]+", "-", phrase.strip().lower()).strip("-")
if not phrase:
raise ValueError("ship-id 短语不能为空")
value = f"{(today or date.today()):%Y%m%d}-{phrase}"
err = check_dated_ship_id(value)
if err:
raise ValueError(err)
return value
def stage_for_step(step: str | None) -> tuple[str, str] | None:
return STAGE_BY_STEP.get(str(step)) if step else None
def ensure_stage_layout(ship_dir: Path) -> None:
"""Create the visible phase folders and their small status manifests."""
for order, (directory, title, steps) in enumerate(STAGE_DEFINITIONS, start=1):
stage_dir = ship_dir / directory
stage_dir.mkdir(parents=True, exist_ok=True)
manifest = stage_dir / "stage.yaml"
if not manifest.exists():
manifest.write_text(
safe_dump({
"order": order,
"stage": directory,
"title": title,
"status": "pending",
"steps": list(steps),
"startedAt": None,
"finishedAt": None,
}),
encoding="utf-8",
)
activity = stage_dir / "activity.md"
if not activity.exists():
activity.write_text(
f"# {directory} {title}\n\n状态:pending\n\n"
"## 活动记录\n\n",
encoding="utf-8",
)
def _stage_manifest(path: Path) -> dict[str, Any]:
try:
value = safe_load(path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
value = None
return value if isinstance(value, dict) else {}
def record_stage_event(
ship_dir: Path,
*,
step: str | None,
status: str,
summary: str = "",
task_id: str | None = None,
) -> None:
"""Append one dispatch/result event to the visible stage audit trail."""
resolved = stage_for_step(step)
if resolved is None:
return
ensure_stage_layout(ship_dir)
directory, title = resolved
stage_dir = ship_dir / directory
manifest_path = stage_dir / "stage.yaml"
manifest = _stage_manifest(manifest_path)
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
event_key = f"{step}|{status}|{task_id or ''}|{summary.strip()}"
if manifest.get("lastEventKey") == event_key:
return
manifest["lastEventKey"] = event_key
if status in {"ready", "in_progress"}:
if manifest.get("status") == "pending":
manifest["startedAt"] = now
manifest["status"] = "in_progress"
elif status in {"pass", "fail", "blocked", "ask"}:
manifest["lastResult"] = status
manifest["lastStep"] = step
if status == "pass":
manifest["status"] = "completed"
manifest["finishedAt"] = now
elif status in {"fail", "blocked"}:
manifest["status"] = "blocked"
manifest_path.write_text(safe_dump(manifest), encoding="utf-8")
line = f"- `{now}` `{step}` status=`{status}`"
if task_id:
line += f" task=`{task_id}`"
if summary:
line += f":{summary.strip()}"
with (stage_dir / "activity.md").open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""Validate a .pouch/ship/<id>/ record. Exit 0 on success, 1 on issues."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
from ship_lib import (
COMMIT_ROLE,
DELIVERY_STATE,
KIND,
SHIP_STATUS,
STOP_AT,
SOURCE_KIND,
TASK_STATUS,
check_ship_id,
load_yaml,
)
from validate_test_manifest import validate_manifest
def _issue(issues: list[str], msg: str) -> None:
issues.append(msg)
def _mapping(value: Any, path: str, issues: list[str]) -> dict[str, Any] | None:
if value is None:
_issue(issues, f"{path} 缺失")
return None
if not isinstance(value, dict):
_issue(issues, f"{path} 必须是 mapping")
return None
return value
def _project_root(ship_dir: Path) -> Path:
# .pouch/ship/<id>
return ship_dir.parent.parent.parent
def validate_ship_dir(ship_dir: Path) -> list[str]:
issues: list[str] = []
ship_dir = ship_dir.resolve()
current_path = ship_dir / "current.yaml"
if not current_path.is_file():
return [f"缺少 {current_path}"]
try:
current = load_yaml(current_path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 current.yaml: {exc}"]
data = _mapping(current, "current.yaml", issues)
if data is None:
return issues
ship_id = data.get("shipId")
if not isinstance(ship_id, str):
_issue(issues, "current.yaml.shipId 必须是字符串")
else:
err = check_ship_id(ship_id)
if err:
_issue(issues, err)
if ship_id != ship_dir.name:
_issue(
issues,
f"目录名与 shipId 不一致: {ship_dir.name} != {ship_id}",
)
kind = data.get("kind")
if kind not in KIND:
_issue(issues, f"kind 无效: {kind!r}(short|long)")
status = data.get("status")
if status not in SHIP_STATUS:
_issue(issues, f"status 无效: {status!r}")
if not isinstance(data.get("title"), str) or not str(data.get("title")).strip():
_issue(issues, "title 不能为空")
for path, value in (
("stopAt", data.get("stopAt")),
("delivery.stopAt", (data.get("delivery") or {}).get("stopAt")
if isinstance(data.get("delivery"), dict) else None),
):
if value not in (None, "") and value not in STOP_AT:
_issue(
issues,
f"{path} 无效: {value!r}({', '.join(sorted(STOP_AT))})",
)
source = data.get("source")
if source is not None:
src = _mapping(source, "source", issues)
if src is not None and src.get("kind") not in SOURCE_KIND:
_issue(issues, f"source.kind 无效: {src.get('kind')!r}")
spec = _mapping(data.get("spec"), "spec", issues)
spec_path = ship_dir / "spec.md"
if not spec_path.is_file():
_issue(issues, "缺少 spec.md")
if spec is not None:
rel = spec.get("path") or "spec.md"
if rel != "spec.md":
_issue(issues, f"spec.path 必须是 spec.md,实际: {rel}")
if kind == "long":
spec_dir = ship_dir / "spec"
if not spec_dir.is_dir():
_issue(issues, "kind=long 缺少 spec/ 目录")
revision = spec.get("revision")
prd = spec.get("prd")
if revision and not prd:
_issue(issues, "long spec.revision 已设但 spec.prd 为空")
if prd:
prd_file = ship_dir / str(prd)
if not prd_file.is_file():
_issue(issues, f"缺少 PRD 文件: {prd}")
handoff = spec.get("handoff")
spec_status = spec.get("specStatus")
if spec_status == "approved" and not handoff:
_issue(issues, "specStatus=approved 但缺少 spec.handoff")
if handoff:
handoff_file = ship_dir / str(handoff)
if not handoff_file.is_file():
_issue(issues, f"缺少 handoff 文件: {handoff}")
elif kind == "short":
for field in (
"revision",
"specStatus",
"prd",
"approvedRevision",
"reviews",
"handoff",
):
if spec.get(field) not in (None, "", []):
_issue(issues, f"kind=short 时 spec.{field} 必须为空")
tasks_meta = _mapping(data.get("tasks"), "tasks", issues)
tasks_rel = "tasks.yaml"
if tasks_meta is not None:
tasks_rel = str(tasks_meta.get("path") or "tasks.yaml")
tasks_path = ship_dir / tasks_rel
if not tasks_path.is_file():
_issue(issues, f"缺少 {tasks_rel}")
else:
issues.extend(_validate_tasks(tasks_path, ship_id, kind))
issues.extend(_validate_manifest_pointers(tasks_path, ship_dir, ship_id))
delivery = data.get("delivery")
if delivery is not None:
deliv = _mapping(delivery, "delivery", issues)
if deliv is not None:
state = deliv.get("state")
if state not in DELIVERY_STATE:
_issue(issues, f"delivery.state 无效: {state!r}")
manager = data.get("manager")
if manager is not None:
mgr = _mapping(manager, "manager", issues)
if mgr is not None:
result = mgr.get("lastResult")
if result not in (None, "pass", "fail", "blocked", "ask"):
_issue(issues, f"manager.lastResult 无效: {result!r}")
in_flight = mgr.get("inFlight")
if in_flight not in (None, "") and not isinstance(in_flight, str):
_issue(issues, "manager.inFlight 必须是字符串或空")
evidence = _mapping(data.get("evidence"), "evidence", issues)
if evidence is not None:
issues.extend(_validate_evidence(evidence, _project_root(ship_dir)))
status_md = ship_dir / "status.md"
if not status_md.is_file():
_issue(issues, "缺少 status.md")
return issues
def _validate_tasks(path: Path, ship_id: Any, kind: Any) -> list[str]:
issues: list[str] = []
try:
data = load_yaml(path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 tasks.yaml: {exc}"]
mapping = _mapping(data, "tasks.yaml", issues)
if mapping is None:
return issues
if mapping.get("shipId") != ship_id:
_issue(
issues,
f"tasks.yaml.shipId 与 current.yaml 不一致: "
f"{mapping.get('shipId')!r} != {ship_id!r}",
)
tasks = mapping.get("tasks")
if not isinstance(tasks, list) or not tasks:
_issue(issues, "tasks.yaml.tasks 必须是非空列表")
return issues
if kind == "short" and len(tasks) != 1:
_issue(issues, f"kind=short 必须恰好 1 条任务,实际 {len(tasks)}")
ids: list[str] = []
for i, item in enumerate(tasks):
prefix = f"tasks[{i}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
tid = row.get("id")
if not isinstance(tid, str) or not tid.strip():
_issue(issues, f"{prefix}.id 不能为空")
else:
if tid in ids:
_issue(issues, f"重复任务 id: {tid}")
ids.append(tid)
if not isinstance(row.get("title"), str) or not str(row.get("title")).strip():
_issue(issues, f"{prefix}.title 不能为空")
depends = row.get("dependsOn") or []
if not isinstance(depends, list):
_issue(issues, f"{prefix}.dependsOn 必须是列表")
status = row.get("status")
if status not in TASK_STATUS:
_issue(issues, f"{prefix}.status 无效: {status!r}")
expected = row.get("expected")
if not isinstance(expected, list) or not expected:
_issue(issues, f"{prefix}.expected 必须是非空列表")
verification = row.get("verification")
if not isinstance(verification, list) or not verification:
_issue(issues, f"{prefix}.verification 必须是非空列表")
test_plan = row.get("testPlan")
if test_plan is not None:
plan = _mapping(test_plan, f"{prefix}.testPlan", issues)
if plan is not None:
policy = plan.get("policy")
if policy not in {"required", "not_applicable"}:
_issue(
issues,
f"{prefix}.testPlan.policy 无效: {policy!r}(required|not_applicable)",
)
manifest = plan.get("manifest")
if not isinstance(manifest, str) or not manifest.strip():
_issue(issues, f"{prefix}.testPlan.manifest 不能为空")
for i, item in enumerate(tasks):
if not isinstance(item, dict):
continue
for dep in item.get("dependsOn") or []:
if dep not in ids:
_issue(issues, f"tasks[{i}].dependsOn 引用未知 id: {dep}")
if dep == item.get("id"):
_issue(issues, f"tasks[{i}] 不能依赖自己")
return issues
def _validate_manifest_pointers(path: Path, ship_dir: Path, ship_id: str) -> list[str]:
issues: list[str] = []
try:
data = load_yaml(path)
except Exception:
return issues
tasks = data.get("tasks") if isinstance(data, dict) else None
if not isinstance(tasks, list):
return issues
for index, item in enumerate(tasks):
if not isinstance(item, dict):
continue
plan = item.get("testPlan")
if not isinstance(plan, dict):
continue
manifest = plan.get("manifest")
if not isinstance(manifest, str) or not manifest.strip():
continue
target = (ship_dir / manifest).resolve()
try:
target.relative_to(ship_dir.resolve())
except ValueError:
_issue(issues, f"tasks[{index}].testPlan.manifest 必须位于 ship 目录内: {manifest}")
continue
if not target.is_file():
_issue(issues, f"tasks[{index}].testPlan.manifest 不存在: {manifest}")
continue
manifest_issues, _ = validate_manifest(
target,
expected_ship_id=ship_id,
policy=str(plan.get("policy") or "required"),
mode="schema",
)
issues.extend(f"tasks[{index}].{issue}" for issue in manifest_issues)
return issues
def _validate_evidence(evidence: dict[str, Any], project_root: Path) -> list[str]:
issues: list[str] = []
for key in ("testRuns", "artifacts", "deploys", "prs", "commits"):
value = evidence.get(key)
if value is None:
_issue(issues, f"evidence.{key} 缺失")
continue
if not isinstance(value, list):
_issue(issues, f"evidence.{key} 必须是列表")
continue
for i, item in enumerate(value):
prefix = f"evidence.{key}[{i}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
if key == "testRuns":
rel = row.get("path")
if not isinstance(rel, str) or not rel.strip():
_issue(issues, f"{prefix}.path 不能为空")
else:
target = project_root / rel
if not target.is_file():
_issue(issues, f"{prefix}.path 不存在: {rel}")
if row.get("result") not in {
"pass",
"fail",
"environment-fail",
"skipped",
"not-run",
"partial",
}:
_issue(issues, f"{prefix}.result 无效: {row.get('result')!r}")
elif key == "artifacts":
if not row.get("sha"):
_issue(issues, f"{prefix}.sha 不能为空")
elif key == "deploys":
if not row.get("env"):
_issue(issues, f"{prefix}.env 不能为空")
elif key == "prs":
if not row.get("url"):
_issue(issues, f"{prefix}.url 不能为空")
elif key == "commits":
if not row.get("sha"):
_issue(issues, f"{prefix}.sha 不能为空")
if row.get("role") not in COMMIT_ROLE:
_issue(issues, f"{prefix}.role 无效: {row.get('role')!r}")
return issues
def main() -> int:
parser = argparse.ArgumentParser(description="校验 ship 记录目录")
parser.add_argument("path", help=".pouch/ship/<id> 或其中的 current.yaml")
args = parser.parse_args()
path = Path(args.path)
ship_dir = path.parent if path.name == "current.yaml" else path
issues = validate_ship_dir(ship_dir)
if issues:
for item in issues:
print(f"✗ {item}", file=sys.stderr)
return 1
print(f"✓ ship 记录通过: {ship_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Validate and inspect a ship test manifest."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from ship_lib import load_yaml
POLICIES = {"required", "not_applicable"}
CHANGES = {"added", "changed"}
KINDS = {"unit", "integration", "e2e", "static"}
MODES = {"schema", "verify", "regress"}
def _issue(issues: list[str], message: str) -> None:
issues.append(message)
def _mapping(value: Any, path: str, issues: list[str]) -> dict[str, Any] | None:
if not isinstance(value, dict):
_issue(issues, f"{path} 必须是 mapping")
return None
return value
def validate_manifest(
manifest_path: Path,
*,
expected_ship_id: str | None = None,
policy: str = "required",
mode: str = "schema",
) -> tuple[list[str], dict[str, Any] | None]:
issues: list[str] = []
if mode not in MODES:
return [f"mode 无效: {mode!r}"], None
try:
data = load_yaml(manifest_path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 test manifest: {exc}"], None
root = _mapping(data, "test-manifest", issues)
if root is None:
return issues, None
ship_id = root.get("shipId")
if not isinstance(ship_id, str) or not ship_id.strip():
_issue(issues, "test-manifest.shipId 不能为空")
elif expected_ship_id and ship_id != expected_ship_id:
_issue(issues, f"test-manifest.shipId 不一致: {ship_id!r} != {expected_ship_id!r}")
tests = root.get("tests")
if not isinstance(tests, list):
_issue(issues, "test-manifest.tests 必须是列表")
tests = []
ids: set[str] = set()
for index, item in enumerate(tests):
prefix = f"tests[{index}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
test_id = row.get("id")
if not isinstance(test_id, str) or not test_id.strip():
_issue(issues, f"{prefix}.id 不能为空")
elif test_id in ids:
_issue(issues, f"重复测试 id: {test_id}")
else:
ids.add(test_id)
for field in ("runner", "target", "selector"):
if not isinstance(row.get(field), str) or not row[field].strip():
_issue(issues, f"{prefix}.{field} 不能为空")
if row.get("kind") not in KINDS:
_issue(issues, f"{prefix}.kind 无效: {row.get('kind')!r}")
if row.get("change") not in CHANGES:
_issue(issues, f"{prefix}.change 无效: {row.get('change')!r}")
covers = row.get("covers")
if not isinstance(covers, list) or not covers or not all(
isinstance(value, str) and value.strip() for value in covers
):
_issue(issues, f"{prefix}.covers 必须是非空字符串列表")
if policy not in POLICIES:
_issue(issues, f"test policy 无效: {policy!r}")
gap = root.get("testGap")
if gap is not None:
gap_row = _mapping(gap, "testGap", issues)
if gap_row is not None:
if not isinstance(gap_row.get("reason"), str) or not gap_row["reason"].strip():
_issue(issues, "testGap.reason 不能为空")
if mode in {"verify", "regress"} and not (
isinstance(gap_row.get("acceptedBy"), str)
and gap_row["acceptedBy"].strip()
):
_issue(issues, "testGap 在 verify/regress 前必须有 acceptedBy")
if mode in {"verify", "regress"}:
if policy == "required" and not tests and gap is None:
_issue(issues, "policy=required 时必须登记至少一个测试,或填写已批准的 testGap")
if policy == "not_applicable" and tests:
_issue(issues, "policy=not_applicable 时 tests 必须为空")
if policy == "not_applicable" and gap is None:
_issue(issues, "policy=not_applicable 时必须说明 testGap.reason")
return issues, root
def main() -> int:
parser = argparse.ArgumentParser(description="校验并列出 ship 测试 manifest")
parser.add_argument("manifest", help="test-manifest.yaml 路径")
parser.add_argument("--ship-id")
parser.add_argument("--policy", default="required", choices=sorted(POLICIES))
parser.add_argument("--mode", default="schema", choices=sorted(MODES))
parser.add_argument("--json", action="store_true", dest="as_json")
args = parser.parse_args()
issues, data = validate_manifest(
Path(args.manifest),
expected_ship_id=args.ship_id,
policy=args.policy,
mode=args.mode,
)
if args.as_json:
print(json.dumps({"ok": not issues, "issues": issues, "tests": (data or {}).get("tests", [])}, ensure_ascii=False))
elif issues:
for issue in issues:
print(f"✗ {issue}", file=sys.stderr)
else:
print(f"✓ test manifest 通过: {args.manifest}")
for row in (data or {}).get("tests", []):
print(f"- {row['id']}: {row['runner']} {row['target']} {row['selector']}")
return 1 if issues else 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,34 @@
shipId: <ship-id>
kind: short
title: <short title>
status: draft
stopAt: null
source:
kind: user
ref: null
parentShipId: null
childShipIds: []
spec:
path: spec.md
revision: null
specStatus: null
prd: null
approvedRevision: null
reviews: null
handoff: null
tasks:
path: tasks.yaml
delivery:
stopAt: null
state: change_ready
manager:
lastStep: null
lastResult: null
lastSummary: null
inFlight: null
evidence:
testRuns: []
artifacts: []
deploys: []
prs: []
commits: []
+21
View File
@@ -0,0 +1,21 @@
# <title>
## 要做什么
<用户可见的目标。>
## 不做什么
<明确排除的范围。>
## 可观察验收
- <能被浏览器、API 或真机看到的结果>
## 怎么验
- <命令、页面路径或手工步骤>
## 故障证据
<没有故障时删除本节。有则写复现步骤和根因。>
+19
View File
@@ -0,0 +1,19 @@
# <title>
- ship id: `<ship-id>`
- kind: `<kind>`
- status: `<status>`
- stopAt: `<stopAt>`
- delivery: `<delivery.state>`
## spec
见 `spec.md`。
## tasks
见 `tasks.yaml`。
## evidence
测试、产物、部署、PR 和 commit 指针写在 `current.yaml` 的 `evidence`。
+14
View File
@@ -0,0 +1,14 @@
shipId: <ship-id>
tasks:
- id: T-01
title: <short title>
dependsOn: []
expected:
- <observable acceptance signal>
verification:
- <how to verify>
testPlan:
policy: required
manifest: 2-develop/test-manifest.yaml
status: open
stopAt: null
@@ -0,0 +1,3 @@
shipId: <ship-id>
tests: []
testGap: null
+31
View File
@@ -0,0 +1,31 @@
# short-ship
把一次范围清楚的改动(小功能、文案、已确认的 bug)从说明推进到配置允许的 PR、合并或部署。
## 什么时候使用
```text
给列表页加一个导出按钮,测完后发 PR。
```
或:
```text
修复这个线上回归,完整验证并部署到测试环境。
```
## 使用前准备
- 同时安装 `ship`(记录格式)和 `delivery-loop`。
- 目标已经说清,或有可复现的问题描述、截图、日志。
- 项目已配置测试环境和所需 builder/tester/deployer 能力。
- 若要外部发布,明确允许的停止点或使用项目 profile。
## Agent 会做什么
主会话只建 ship 记录、写短说明、按步骤开新 agent 并等待结果。改代码、测试、开合 PR、
构建和部署都在新会话里做,不会堆进同一个上下文。一次交付一个 ship id。
## 如何判断完成
你会得到 ship id、说明、测试证据、PR/合并/tag、产物和部署状态;不会只收到“本地测试通过”。
+59
View File
@@ -0,0 +1,59 @@
---
name: short-ship
description: >-
处理范围已清楚的单次变更:小功能、文案、加按钮,或已有证据的 bug。建立
ship 记录、改代码、验证,再按配置发 PR、合并、构建或部署。触发词:short-ship、
小需求、小改动、修 bug、改文案、加按钮。复杂需求用 long-ship;已有 PR 用 pr-ship。
---
# Short Ship:单次变更到交付
当前会话只做管理:建账本、问用户、按 `next_step.py` 开新 agent 并等待。不要把
hunt / 改代码 / 测试 / 交付读进本会话。
## 何时使用
- 加按钮、改文案、改短描述、单页小功能,目标与验收已经明确。
- 用户报告错误、回归、崩溃或明确要求修 bug,并且能指出问题。
- 用户要求改完后继续测试、发 PR、合并、构建、发布或部署。
## 不适用
- 跨模块、要方案取舍、要多任务协作:使用 `long-ship` 和 `specifier`。
- 已有 PR 的合并和发布:使用 `pr-ship`。
- 只做根因分析、不改代码:使用 `hunt`。
## 工作规则
1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读
[worker-prompt.md](../ship/references/worker-prompt.md)。没有 `ship-id` 就生成
slug,跑 `create_ship.py --kind short`。功能任务不要写入 `.pouch/ack/tasks.yaml`。
2. 主会话只写 `spec.md` 四段和那一条 task。有故障证据时 `next_step.py` 会给出
`hunt` worker,不要在本会话读 hunt。
3. 之后只跑:
```bash
python3 <ship-skill>/scripts/next_step.py \
--project-root <root> --ship-id <id> --entry short
```
`actor: worker` 就开新 agent 并等待;`ask_user` / `blocked` 就停下来问用户。
需要字段含义时再读 [record.md](../ship/references/record.md)。
4. 范围涨到需要 PRD 或多条任务时,同一 `ship-id` 把 `kind` 改为 `long`,改用
`--entry long`。
不要读取 `delivery-loop`、`tester`、`builder`、`deployer`、`check`、`hunt`。
这些由对应 worker 按 JSON 的 `skill` 加载。
## 安全门
- 没有 ship-id、spec.md 四段不齐、验收不可观察时停止。
- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。
- 根因不清、测试缺失、真机不可达、CI/回归失败或工作树有无关改动时停止并说明。
- 不强推、不覆盖他人分支、不绕过 PR 审核,不把测试环境成功当生产成功。
- 数据迁移、破坏性 API、权限/密钥、安全修复和不可逆生产操作需要额外确认。
## 完成报告
报告 ship id、spec 路径、根因(若有)、变更、测试/真机证据指针、停止点、commit、
PR/tag、产物摘要、部署状态和未完成项。数字来自账本,不要复述 worker 技能正文。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Short Ship"
short_description: "Ship a small change or bug fix through verification and delivery"
default_prompt: "You are the short-ship manager: keep the ship record, spawn a fresh agent for each next_step, and wait. Do not hunt, code, test, or deliver in this session."
policy:
allow_implicit_invocation: true
+9 -8
View File
@@ -1,17 +1,18 @@
# specifier
把复杂需求整理成经过多轮人工评审的 HTML PRD,并在批准后生成交给 ACK 执行的需求 handoff。
把复杂需求整理成经过多轮人工评审的 HTML PRD,并在批准后生成交给 long-ship 执行的需求 handoff。
## 什么时候使用
- 要做跨模块、跨页面、跨服务或带数据迁移的复杂功能。
- 要先写 PRD、比较方案、梳理风险,再进入开发。
- 需要把 PRD 给人反复评审,确认后再交给 `/ack`。
- 需要把 PRD 给人反复评审,确认后再交给 `long-ship`。
小改动直接使用 `/ack`;已知 Bug 使用 `/hunt`;单次架构取舍使用 `/think`。
小改动直接使用 `short-ship`;已知 Bug 使用 `/hunt`;单次架构取舍使用 `/think`。
## 使用前准备
- 同时安装 `ship`;批准后的开发走 `long-ship`。
- 项目应位于 Git 仓库中,便于读取现有代码、文档和历史决策。
- 用户需要参与 PRD 评审,并明确批准具体版本。
- 不需要外部账号或网络资源;生成的 HTML 应能离线打开。
@@ -20,7 +21,7 @@
```text
用 specifier 处理“给后台增加多租户权限管理”这个复杂需求:先调研现状,生成 HTML PRD,
我会逐轮评审。批准后再生成 ack handoff,不要提前派开发。
我会逐轮评审。批准后再生成 long-ship handoff,不要提前派开发。
```
## Agent 会做什么
@@ -29,14 +30,14 @@
2. 形成目标、范围、非目标、方案、交互/架构、风险、依赖和可观测验收。
3. 生成可离线打开的版本化 HTML PRD;保留每个历史版本并记录本轮变更。
4. 根据用户意见反复修改,直到用户明确批准指定版本。
5. 仅在批准后生成 `handoff.yaml`,供 `/ack` 创建和执行任务。
5. 仅在批准后生成 `spec/handoff.yaml`,供 `long-ship` 创建和执行任务。
6. 用户需要远程查看,或说“把 HTML 渲染出来”时,可调用独立的 `previewer` skill;它用 Node
渲染并通过 Tailscale 暴露为 HTTP URL,同时返回应该使用的 Tailscale IP。
Specifier 只负责需求规格,不修改业务代码,也不替代 ACK 的开发和独立测试。
Specifier 只负责需求规格,不修改业务代码,也不替代 long-ship 的开发和独立测试。
## 如何判断完成
- 评审阶段:得到一个带版本号的 HTML PRD、变更记录、风险和未决问题。
- 交接阶段:用户明确批准某个版本,且 `handoff.yaml` 绑定了该版本和完整验收信息。
- 开发阶段:改用 `/ack`,由 ACK 负责 Developer、Test、回归和交付闭环。
- 交接阶段:用户明确批准某个版本,且 `spec/handoff.yaml` 绑定了该版本和完整验收信息。
- 开发阶段:改用 `long-ship`,任务和证据写在同一个 ship id 下。
+50 -38
View File
@@ -2,14 +2,15 @@
name: specifier
description: >-
把复杂或跨模块需求整理成可评审、可版本化的 HTML PRD,并在用户明确批准某个版本后
生成交给 ack 的结构化 handoff。用户说做复杂功能、写 PRD、梳理需求、设计方案、需求
评审、反复改需求或交给 ack 开发时使用;小改动、Bug 根因排查和单纯架构讨论不使用。
生成交给 long-ship 的结构化 handoff。用户说做复杂功能、写 PRD、梳理需求、设计方案、
需求评审、反复改需求或交给 long-ship 开发时使用;小改动、Bug 根因排查和单纯架构讨论
不使用。
---
# Specifier:复杂需求规格化
把模糊需求变成经过人确认的需求基线,再交给 `ack` 执行。HTML 是给人评审的正式交付物,
结构化状态和 handoff 是给 Agent/ACK 消费的机器接口。
把模糊需求变成经过人确认的需求基线,再交给 `long-ship` 执行。HTML 是给人评审的正式交付物,
结构化状态和 handoff 是给 ship 记录消费的机器接口。
---
@@ -17,61 +18,70 @@ description: >-
- 用户要做跨模块、跨页面、跨服务或有数据迁移的复杂功能。
- 用户要求写 PRD、产品方案、需求规格、架构/交互方案,并需要多轮确认。
- 用户要求把确认后的需求交给 `/ack` 开发。
- 用户要求把确认后的需求交给 `long-ship` 开发。
## 不适用
- 单文件或范围明确的小改动:直接交给 `/ack`。
- 已有复现证据的 Bug:使用 `/hunt` 找根因,再按需要交给 `/ack`。
- 单文件或范围明确的小改动:使用 `short-ship`。
- 已有复现证据的 Bug:使用 `/hunt` 找根因,再按需要交给 `short-ship`。
- 只问一个技术选型或一次性方案判断:使用 `/think`。
- 用户尚未批准的需求:不得创建 ACK 任务、派 worker、改业务代码或开始交付。
- 用户尚未批准的需求:不得创建开发任务、派 worker、改业务代码或开始交付。
---
## 工作目录和事实源
每个需求使用独立目录 `.pouch/specifier/features/<feature-id>/`。开始前检查目录是否已存在,
不要覆盖既有版本;已有需求要进入对应版本的 `review` 或 `revise` 模式。
每个需求使用独立 ship 目录 `.pouch/ship/<ship-id>/`。规格文件写在 `spec/`。开始前读取
已安装的 `ship` skill;已有需求要进入对应版本的 `review` 或 `revise` 模式。
```text
.pouch/specifier/features/<feature-id>/
├── current.yaml # 当前版本、状态和批准信息
├── brief.md # 需求输入和现状摘要
├── prd-v001.html # 人类评审版本,不覆盖旧版本
├── review.yaml # 每轮意见、修改请求和确认
├── decisions.yaml # 关键方案决策
├── risks.yaml # 风险、依赖和缓解措施
└── handoff.yaml # 仅 approved 后生成,交给 ack
.pouch/ship/<ship-id>/
├── current.yaml # ship 总账;spec.revision / specStatus 在这里
├── spec.md # 短摘要
├── tasks.yaml # 批准后由 long-ship 写入,本 skill 不写
└── spec/
├── brief.md
├── prd-v001.html
├── review.yaml
├── decisions.yaml
├── risks.yaml
└── handoff.yaml # 仅 approved 后生成,交给 long-ship
```
目录名使用稳定、短、小写连字符的 feature ID。若用户未提供,先从需求生成候选并说明;
目录名使用稳定、短、小写连字符的 ship id。若用户未提供,先从需求生成候选并说明;
不要用日期、随机数或当前分支名冒充业务 ID。
`.pouch/specifier/` 只保存需求规格状态,不复制 skill 内容,不修改项目的 `AGENTS.md`、
`CLAUDE.md` 或已有 ACK 状态。HTML 可引用仓库中的文档和代码路径,但不能依赖外部网络资源。
若只有旧路径 `.pouch/specifier/features/<id>/`,按 ship `record.md` 迁到
`.pouch/ship/<id>/spec/` 后再继续。不要两处同时写。
`.pouch/ship/<id>/spec/` 只保存需求规格,不复制 skill 内容,不修改项目的 `AGENTS.md`、
`CLAUDE.md` 或 ACK 缺陷板。HTML 可引用仓库中的文档和代码路径,但不能依赖外部网络资源。
## 模式路由
- **新建**:探查现状,建立 brief,生成 `prd-v001.html`,状态为 `review`。
- **评审 / 修改**:读取当前版本和 `review.yaml`,把用户意见转成修改项,生成新版本,
- **新建**:探查现状,建立 `spec/brief.md`,生成 `spec/prd-v001.html`,把
`spec.specStatus` 设为 `review`。
- **评审 / 修改**:读取当前版本和 `spec/review.yaml`,把用户意见转成修改项,生成新版本,
展示变更摘要和仍未决的问题。
- **批准**:只在用户明确批准指定版本后,把状态改为 `approved`,冻结需求基线。
- **交接**:仅针对 `approved` 版本生成 `handoff.yaml`;用户要求继续开发时把它交给 `/ack`。
- **批准**:只在用户明确批准指定版本后,把 `spec.specStatus` 改为 `approved`,冻结需求基线。
- **交接**:仅针对 `approved` 版本生成 `spec/handoff.yaml`;用户要求继续开发时把它交给 `long-ship`。
- **状态**:只读报告当前版本、待决策、风险和是否可以交接。
- 作为 `long-ship` 派来的 worker:只做 prompt 里的那一个模式,写完 `spec/` 就返回;
不要再调用 `long-ship`,不要改业务代码。
详细评审状态机和确认规则见 [review-loop.md](references/review-loop.md)。HTML 页面结构和
渲染要求见 [html-prd.md](references/html-prd.md)。只有需要对应模式时才读取它们。
## 新建和调研
1. 先判断需求是否真的复杂;不复杂时给出转 `/ack` 的建议,不强行生成 PRD。
1. 先判断需求是否真的复杂;不复杂时给出转 `short-ship` 的建议,不强行生成 PRD。
2. 读取项目覆盖层、相关文档、历史决策和代码;优先确认现有能力、模块边界、数据模型、
API、权限、运行约束和回归用例。不要从 README 或猜测中虚构项目事实。
3. 形成现状与目标差异:背景、用户、场景、目标、非目标、范围、约束、成功标准。
4. 对关键方案给出取舍,记录推荐方案、被否决方案、最脆弱假设、风险、依赖和回滚方向。
5. 描述必要的产品流程、交互状态、领域模型、状态流、API/数据变化和非功能要求。
6. 按可独立验证的垂直切片拆任务,写出依赖、关键路径、`expected` 和 `verification`,
但不要写入 `.pouch/ack/tasks.yaml`。
但不要写入 `.pouch/ack/tasks.yaml` 或本 ship 的 `tasks.yaml`(任务由 long-ship 在批准后创建)。
7. 按模板生成自包含 HTML PRD,并在回复中说明文件路径、版本、待确认问题和下一步。
8. 用户需要在其它设备查看 HTML,或说“把这个 HTML 渲染出来”时,调用已安装的
`previewer` 能力传入该文件;不要在 `specifier` 内复制 Node/Tailscale Serve 逻辑。
@@ -98,20 +108,21 @@ description: >-
需求评审状态,HTML 被展示不等于用户批准。回复中要同时给出 Tailscale IP 和 URL,不能只
说“已经渲染”或只返回本机 `127.0.0.1`。
## 批准和 ACK 交接
## 批准和 long-ship 交接
批准前只允许写 specifier 自己的需求目录。批准后仍不自动启动 worker;先生成 `handoff.yaml`,
其中必须绑定:feature ID、PRD 版本和路径、需求范围、非目标、决策、任务依赖、验收信号、
风险、回滚/发布注意事项和回归候选。
批准前只允许写该 ship 的 spec 目录和 `current.yaml` 的 spec 字段。批准后仍不自动启动
worker;先生成 `spec/handoff.yaml`,其中必须绑定:ship id、PRD 版本和路径、需求范围、
非目标、决策、任务依赖、验收信号、风险、回滚/发布注意事项和回归候选。同步
`current.yaml` 的 `spec.specStatus`、`spec.approvedRevision`、`spec.handoff`。
`handoff.yaml` 是 ACK 的输入,不是 ACK 的执行记录。用户明确要求继续开发时,提示使用:
`handoff.yaml` 是 `long-ship` 的输入,不是执行记录。用户明确要求继续开发时,提示使用:
```text
/ack 使用 specifier handoff:.pouch/specifier/features/<feature-id>/handoff.yaml
/long-ship 使用 handoff:.pouch/ship/<ship-id>/spec/handoff.yaml
```
ACK 应引用批准的 HTML 版本,不复制另一份可漂移的 PRD。若 ACK 不存在、handoff 缺字段或
需求状态不是 `approved`,停止交接并报告缺口。
long-ship 应引用批准的 HTML 版本,不复制另一份可漂移的 PRD。handoff 缺字段或需求状态
不是 `approved` 时,停止交接并报告缺口。
## 安全边界
@@ -123,6 +134,7 @@ ACK 应引用批准的 HTML 版本,不复制另一份可漂移的 PRD。若 AC
## 验证
完成前检查:HTML 可离线打开、版本号与 `current.yaml` 一致、旧版本未被覆盖、每个未决问题
有明确状态、每个任务有可观测验收、handoff 只在 `approved` 后存在且绑定正确的 PRD 版本。
需要交给 ACK 时,先确认 handoff schema 与用户批准版本一致,再停止等待 ACK。
完成前检查:HTML 可离线打开、版本号与 ship `current.yaml` 的 `spec.revision` 一致、旧版本
未被覆盖、每个未决问题有明确状态、每个任务有可观测验收、handoff 只在 `approved` 后存在
且绑定正确的 PRD 版本。需要交给 long-ship 时,先确认 handoff schema 与用户批准版本一致,
再停止等待 long-ship。
+1 -1
View File
@@ -1,6 +1,6 @@
interface:
display_name: "Specifier"
short_description: "Draft and review complex requirements as HTML PRDs"
default_prompt: "Use $specifier to turn this complex feature request into a reviewable HTML PRD."
default_prompt: "Use $specifier to turn this complex feature request into a reviewable HTML PRD in the ship record."
policy:
allow_implicit_invocation: true
+2 -2
View File
@@ -28,7 +28,7 @@ HTML 是给人离线评审的交付物,不是运行中的产品页面。它必
- 颜色和状态不能只依靠颜色表达;为状态提供文字标签。
- 长表格允许横向滚动,代码或 API 示例使用 `pre`,正文保持可复制。
- `h1` 只有一个;标题层级连续;链接和表格有可读的焦点/边框状态。
- 页首显示 feature ID、PRD 版本、状态、生成时间和基于的版本。
- 页首显示 ship id、PRD 版本、状态、生成时间和基于的版本。
- 变更内容用文字摘要或 `ins` / `del` 标记表达,不要只用颜色标记。
## 内容质量
@@ -36,4 +36,4 @@ HTML 是给人离线评审的交付物,不是运行中的产品页面。它必
- 把“体验好”“性能足够”“支持权限”等不可验证表述改成可观察信号或明确待决策项。
- 不把实现方案伪装成用户需求;需求、决策和任务分开写。
- 未知事实写“待确认”,并加入未决问题,不填看似合理的数字、接口或页面。
- 每条验收都能被 ACK 的 Test 按步骤观察;测试工具不可用时仍能区分产品失败和环境失败。
- 每条验收都能被 long-ship / Test 按步骤观察;测试工具不可用时仍能区分产品失败和环境失败。
+15 -12
View File
@@ -9,15 +9,17 @@
## 状态
`current.yaml` 至少记录:
ship `current.yaml` 的 spec 字段至少记录:
```yaml
featureId: tenant-permissions
currentRevision: v002
status: review
prd: prd-v002.html
approvedRevision: null
reviews: review.yaml
shipId: tenant-permissions
kind: long
spec:
revision: v002
specStatus: review
prd: spec/prd-v002.html
approvedRevision: null
reviews: spec/review.yaml
```
允许的状态:
@@ -55,11 +57,11 @@ reviews: review.yaml
## 每轮评审
1. 读取 `current.yaml`、当前 HTML 和所有未解决的 review 项;不要只读用户最新一句话。
1. 读取 ship `current.yaml`、当前 HTML 和所有未解决的 review 项;不要只读用户最新一句话。
2. 将新反馈归类:`change`、`decision`、`question`、`rejected`、`parked`。
3. 说明每个反馈对范围、架构、任务和验收的影响。
4. 生成新版本 HTML,保留旧文件。
5. 更新 `current.yaml` 和 `review.yaml`,并在回复中给出:新版本路径、变更摘要、未决问题、
5. 更新 ship `current.yaml` 的 spec 字段和 `spec/review.yaml`,并在回复中给出:新版本路径、变更摘要、未决问题、
是否仍需批准。
如果反馈只影响措辞,也生成新版本;评审记录需要保持可追溯。若用户明确说不采纳,记录理由,
@@ -82,7 +84,8 @@ reviews: review.yaml
- HTML 版本、状态文件和 review 记录一致。
如果用户说“继续”“可以”“看起来不错”,但没有绑定版本,继续保持 `review`,请用户确认
具体版本。批准后把 `approvedRevision` 写入 `current.yaml`,再生成 handoff;不要同时启动 ACK。
具体版本。批准后把 `spec.approvedRevision` 写入 ship `current.yaml`,再生成
`spec/handoff.yaml`;不要同时启动 long-ship 或 ACK。
## 批准后的变更
@@ -94,5 +97,5 @@ reviews: review.yaml
4. 标记受影响的任务、验收、风险和 ACK handoff 字段;
5. 重新等待用户批准。
已经进入 ACK 的任务不会因 HTML 更新而自动改变。需要执行变更时,明确告诉用户应创建新的
ACK 任务或由 ACK 重新规划,不能静默改写执行记录。
已经进入 long-ship 的任务不会因 HTML 更新而自动改变。需要执行变更时,明确告诉用户应
修订本 ship 的 `tasks.yaml` 或重新规划,不能静默改写执行记录。
@@ -1,6 +1,16 @@
featureId: <feature-id>
currentRevision: v001
status: review
prd: prd-v001.html
approvedRevision: null
reviews: review.yaml
# 已并入 ship current.yaml 的 spec 字段。新建需求请用 ship 模板,不要单独写这份文件。
# 迁移旧 .pouch/specifier/features/<id>/current.yaml 时按此对照:
# featureId -> shipId
# currentRevision -> spec.revision
# status -> spec.specStatus
# prd -> spec.prd(路径加上 spec/ 前缀)
# approvedRevision -> spec.approvedRevision
# reviews -> spec.reviews
shipId: <ship-id>
spec:
revision: v001
specStatus: review
prd: spec/prd-v001.html
approvedRevision: null
reviews: spec/review.yaml
handoff: null
@@ -1,8 +1,8 @@
featureId: <feature-id>
shipId: <ship-id>
specifierRevision: v001
status: approved
prd:
path: prd-v001.html
path: spec/prd-v001.html
revision: v001
scope:
goals: []
+1 -1
View File
@@ -30,7 +30,7 @@
<main>
<article>
<header>
<p class="meta"><span class="badge">Feature: {{feature_id}}</span><span class="badge">PRD: {{revision}}</span><span class="badge status">状态:{{status}}</span></p>
<p class="meta"><span class="badge">Ship: {{ship_id}}</span><span class="badge">PRD: {{revision}}</span><span class="badge status">状态:{{status}}</span></p>
<h1>{{feature_title}}</h1>
<p>{{one_sentence_summary}}</p>
<p class="muted">生成时间:{{generated_at}} · 基于版本:{{changed_from}}</p>
+14
View File
@@ -0,0 +1,14 @@
# test-preflight
Reusable preflight for tester matrices. It supports read-only `verify` and
test-environment-only `prepare`, and writes per-check YAML/CSV evidence for
artifacts, tester nodes, devices, network, services, and fixture/cleanup contracts.
This project assumes mobile album/photo/IME capabilities; journey cases exercise them
and preflight does not gate them or read authorization environment variables.
Run the self-tests with:
```bash
python3 -m unittest discover -s skills/test-preflight/tests
python3 /home/ace/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/test-preflight
```
+58
View File
@@ -0,0 +1,58 @@
---
name: test-preflight
description: >-
Verify or prepare a mobile/integration test environment from a declared matrix:
artifacts, tester bindings, HOST/PUBLIC_HOST, service probes, and fixture/cleanup
contracts. Use before tester runs; it is not a deployment,
build, or business-data skill.
---
# test-preflight
Use this skill before `integration` or `journey` tests. It answers whether the
declared environment can start a run and emits one YAML and one CSV evidence
file. It never logs in, creates business data, builds/downloads an artifact, or
deploys production.
## Modes
- `verify` is read-only: validate the matrix, artifact identity, binding, actual
tester-node platform/device state, DNS/TCP/HTTP probes, backend probes, and
fixture/cleanup declarations. It must not install, launch, or deploy.
- `prepare` is side-effecting only within the test environment: call the
caller-supplied deployer command contract, run the same checks, then use the
tester-node command contract to install and launch the already supplied APK or
IPA. It never calls a builder and refuses `environment: prod`.
The command is:
```bash
python3 <skill-dir>/scripts/preflight.py verify \
--config <matrix.yaml> --yaml <preflight.yaml> --csv <preflight.csv>
python3 <skill-dir>/scripts/preflight.py prepare \
--config <matrix.yaml> --yaml <preflight.yaml> --csv <preflight.csv>
```
Read [references/schema.md](references/schema.md) for the input contract and
[references/boundaries.md](references/boundaries.md) before wiring a real
agent-runtime/deployer adapter. The included command-contract fixture is the
safe way to test remote-node behavior without pretending that this machine is a
Mac or that a deployment happened.
Overall status is `ready`, `blocked`, or `environment-fail`:
- `blocked` means a required declaration, artifact, binding, runner, fixture,
cleanup contract, or authorization is absent/unsafe.
- `environment-fail` means a declared environment exists but DNS/TCP/HTTP,
device/toolchain, installation, launch, or deployment execution failed.
- `ready` means every recorded check passed. A ready preflight permits the
caller to continue with `integration`; this skill does not run test cases.
This project assumes the client has the required album picker, photo permission, and Chinese
IME capabilities. Preflight does not probe or gate these capabilities, and does not read
`AUTHORIZE_ALBUM` or `IME_PINYIN`. Their real behavior is exercised by journey cases; a failure
there is a product test failure, not an environment block.
Do not treat a preflight report as a product result. A preflight failure means
no client session may be created; the tester owns `not-run` propagation and must write
one result row per selected testcase with `blocked_by: [test-preflight]`.
+3
View File
@@ -0,0 +1,3 @@
interface:
display_name: "Test Preflight"
short_description: "Verify or prepare tester environments"
@@ -0,0 +1,4 @@
# Reference moved
Read [schema.md](schema.md) for the matrix and evidence contract, and
[boundaries.md](boundaries.md) for agent-runtime/deployer/builder integration.
@@ -0,0 +1,44 @@
# Integration boundaries
`test-preflight` is an orchestration and evidence layer. It consumes existing
skills instead of implementing their policy or storage formats.
| Boundary | Caller-supplied contract | What preflight does | What it must not do |
| --- | --- | --- | --- |
| agent-runtime | resolved `tester.<name>` binding: binding id, node, environment, project/path, and a node runner | validate that the binding is explicit and route node probes through the runner | parse Orca state, invent a host, or create/reuse a terminal |
| deployer | `prepare` only: an argv command returning a JSON deployment receipt for `environment: test` | call it once and record its receipt/status | copy Compose/Argo logic, SSH directly, or accept production |
| builder/obtain | caller-provided local artifact path (and optional remote path), hash, source commit, package/bundle identity | hash and inspect the exact file; pass it to install contract | build, guess an old artifact, or silently download/upload |
## Deployer command contract
`prepare` requires:
```yaml
deployment:
environment: test
command: [python3, /path/to/existing-deployer-adapter.py]
```
The command receives JSON on stdin:
```json
{"operation":"prepare","environment":"test","project_root":"/abs/project","host":"...","public_host":"..."}
```
It must return `{ "status": "ready", "observed": "..." }` (an optional
`checks` mapping is recorded). Non-zero/invalid output is an
`environment-fail`; absent command or a non-test environment is `blocked`.
The adapter owns deployer-specific configuration, receipts, cleanup, and
authorization.
## Device contract
The node runner owns remote execution. For a local node the implementation uses
read-only `adb`/`xcrun simctl` checks in `verify`; `prepare` alone invokes
`adb install`/launch or `simctl install`/launch. For a remote node the same
operations are JSON requests to the runner. Missing xcrun on Linux, Android
`unauthorized`/`offline`, a missing device, or a failed install/start is an
`environment-fail`, not a product failure.
No client session is created by this skill. The runner's `install` and `start`
operations are the only device mutations, and are never called by `verify`.
@@ -0,0 +1,95 @@
# test-preflight matrix schema
The input is YAML. `mode` is supplied by the CLI, so a file may be reused for
`verify` and `prepare`.
```yaml
schema_version: 1
project_root: /absolute/project/root
host: https://test.example.invalid
public_host: https://public-test.example.invalid # optional
service_probes:
- id: api-health
url: "{HOST}/health"
expected_status: [200]
fixtures:
required: true
references: [isolated-test-account]
cleanup:
required: true
steps: [delete-run-data]
# Optional project metadata. It is not a preflight gate.
capabilities:
assumed: [album_picker, photo_permission, ime_pinyin]
matrix:
- platform: android
tester:
binding: tester.android
node: local
runner: {kind: local}
artifact:
path: /absolute/dist/writes.apk
sha256: 012345...
package: page.writes
source_commit: 40-character-commit-sha
identity_command: [apkanalyzer, manifest, application-id, "{artifact}"]
```
Required matrix fields are `platform` (`android` or `ios`), `tester.binding`,
`tester.node`, and `artifact.path`. Artifact identity is required as
`package` for Android or `bundle_id` for iOS. `sha256` and `source_commit` are
optional but recommended; when present they are checked exactly.
`service_probes` must contain at least one URL. `{HOST}` and `{PUBLIC_HOST}`
are expanded from the top-level values. Each probe is checked from the actual
tester node as DNS, TCP, and HTTP. A status outside 2xx–4xx fails transport
readiness; `expected_status` can require an exact allow-list. HTTP 401/403 can
therefore be a valid reachability result when explicitly expected.
`fixtures` and `cleanup` are declarations, not commands. A required fixture
needs non-empty `references`; a required cleanup needs non-empty `steps` or
`run`. For a read-only run, use `required: false` with a non-empty `reason`.
The skill never executes these values.
## Client capabilities
The project assumes the mobile test nodes provide the album picker, photo permission,
test image, and Chinese IME. These are not preflight declarations or gates. Journey
cases exercise the real UI; failures are recorded as product failures. `AUTHORIZE_ALBUM`
and `IME_PINYIN` are not read.
## Tester-node contract
`tester.runner.kind: local` executes platform and network probes on the current
node. It is only valid for `node: local`/`localhost`. A non-local node must use
`kind: contract` and a non-empty argv `command`. The command receives one JSON
request on stdin and returns one JSON object on stdout; no shell string is
accepted.
Requests have `operation` (`platform_probe`, `network_probe`, `install`, or
`start`), `platform`, `node`, and operation-specific fields. The response is:
```json
{
"status": "ready",
"observed": "short evidence",
"checks": {
"macos": {"status": "ready", "observed": "Darwin 24", "expected": "Darwin"}
}
}
```
`checks` is optional; each key becomes a separate evidence row. A non-zero,
invalid, or unavailable runner is `environment-fail`. The adapter is expected
to execute on the resolved tester node, not on the coordinator. See
[boundaries.md](boundaries.md).
## Evidence
YAML contains `mode`, overall `status`, timestamps, a summary, and `checks`.
CSV uses these stable columns:
`check_id,mode,platform,node,target,observed,expected,status,remediation,timestamp`
Every check must use one of `ready`, `blocked`, or `environment-fail`; the
report does not invent `fail` or `not-run` testcase results.
+553
View File
@@ -0,0 +1,553 @@
#!/usr/bin/env python3
"""Run the test-preflight verify/prepare contract."""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import platform as host_platform
import plistlib
import re
import shutil
import socket
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping
try:
import yaml
except ImportError as exc: # pragma: no cover
raise SystemExit("test-preflight 需要 PyYAML;请安装运行依赖后重试") from exc
PLATFORMS = {"android", "ios"}
STATUSES = {"ready", "blocked", "environment-fail"}
EVIDENCE_FIELDS = [
"check_id", "mode", "platform", "node", "target", "observed", "expected",
"status", "remediation", "timestamp",
]
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
COMMIT = re.compile(r"^[0-9a-fA-F]{40}$")
def now() -> str:
return datetime.now(timezone.utc).isoformat()
def text(value: Any) -> str:
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, sort_keys=True)
return "" if value is None else str(value)
def status_from(value: Any, default: str = "environment-fail") -> str:
return value if value in STATUSES else default
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def command_text(command: list[str], *, payload: Mapping[str, Any] | None = None) -> tuple[int, str]:
try:
process = subprocess.run(
command,
input=(json.dumps(payload, ensure_ascii=False) if payload is not None else None),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
except OSError as exc:
return 127, str(exc)
return process.returncode, process.stdout.strip()
class Evidence:
def __init__(self, mode: str) -> None:
self.mode = mode
self.started_at = now()
self.rows: list[dict[str, str]] = []
def add(
self,
check_id: str,
*,
platform: str = "all",
node: str = "coordinator",
target: str = "",
observed: Any = "",
expected: Any = "",
status: str,
remediation: str = "",
) -> None:
if status not in STATUSES:
raise ValueError(f"invalid evidence status: {status}")
self.rows.append({
"check_id": check_id,
"mode": self.mode,
"platform": platform,
"node": node,
"target": target,
"observed": text(observed),
"expected": text(expected),
"status": status,
"remediation": remediation,
"timestamp": now(),
})
def overall(self) -> str:
statuses = {row["status"] for row in self.rows}
if "environment-fail" in statuses:
return "environment-fail"
if "blocked" in statuses:
return "blocked"
return "ready"
def payload(self) -> dict[str, Any]:
return {
"schema_version": 1,
"mode": self.mode,
"status": self.overall(),
"started_at": self.started_at,
"finished_at": now(),
"summary": {state: sum(row["status"] == state for row in self.rows) for state in sorted(STATUSES)},
"checks": self.rows,
}
def load_config(path: Path) -> dict[str, Any]:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("配置根必须是 mapping")
return data
def expand(value: str, config: Mapping[str, Any]) -> str:
return value.replace("{HOST}", text(config.get("host"))).replace("{PUBLIC_HOST}", text(config.get("public_host")))
def normalize_url(value: str) -> str:
return value if urllib.parse.urlsplit(value).scheme else "http://" + value.lstrip("/")
def argv(value: Any) -> list[str] | None:
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value):
return None
return value
def validate_shape(config: Mapping[str, Any], evidence: Evidence) -> bool:
ok = True
if config.get("schema_version") != 1:
evidence.add("config-schema", target="config", observed=config.get("schema_version"), expected="1", status="blocked", remediation="将 schema_version 设为 1")
ok = False
if not isinstance(config.get("project_root"), str) or not Path(text(config.get("project_root"))).is_absolute():
evidence.add("config-project-root", target="project_root", observed=config.get("project_root"), expected="absolute project_root", status="blocked", remediation="提供项目根绝对路径")
ok = False
if not isinstance(config.get("host"), str) or not config["host"].strip():
evidence.add("config-host", target="HOST", expected="non-empty host URL", status="blocked", remediation="提供 HOST")
ok = False
probes = config.get("service_probes")
if not isinstance(probes, list) or not probes:
evidence.add("config-service-probes", target="service_probes", expected="at least one probe", status="blocked", remediation="声明服务 URL 探针")
ok = False
else:
for index, probe in enumerate(probes):
if not isinstance(probe, dict) or not isinstance(probe.get("url"), str) or not probe["url"].strip():
evidence.add(f"config-service-probe-{index}", target=f"service_probes[{index}]", expected="url", status="blocked", remediation="为每个服务探针提供 URL")
ok = False
matrix = config.get("matrix")
if not isinstance(matrix, list) or not matrix:
evidence.add("config-matrix", target="matrix", expected="non-empty list", status="blocked", remediation="声明至少一个平台矩阵行")
return False
seen: set[str] = set()
for index, row in enumerate(matrix):
prefix = f"matrix-{index}"
if not isinstance(row, dict):
evidence.add(prefix, target=f"matrix[{index}]", expected="mapping", status="blocked", remediation="修正矩阵行")
ok = False
continue
platform_name = text(row.get("platform")).lower()
if platform_name not in PLATFORMS or platform_name in seen:
evidence.add(f"{prefix}-platform", platform=platform_name or "unknown", target=f"matrix[{index}].platform", expected="unique android or ios", status="blocked", remediation="为每个平台保留唯一矩阵行")
ok = False
seen.add(platform_name)
tester = row.get("tester")
if not isinstance(tester, dict) or not isinstance(tester.get("binding"), str) or not tester["binding"].startswith("tester."):
evidence.add(f"{prefix}-binding", platform=platform_name, target=f"matrix[{index}].tester.binding", expected="tester.<name>", status="blocked", remediation="由 agent-runtime 解析并传入 tester binding")
ok = False
if not isinstance(tester, dict) or not isinstance(tester.get("node"), str) or not tester["node"].strip():
evidence.add(f"{prefix}-node", platform=platform_name, target=f"matrix[{index}].tester.node", expected="actual tester node", status="blocked", remediation="提供实际 tester 节点名")
ok = False
artifact = row.get("artifact")
identity_key = "package" if platform_name == "android" else "bundle_id"
if not isinstance(artifact, dict) or not isinstance(artifact.get("path"), str) or not artifact["path"].strip():
evidence.add(f"{prefix}-artifact", platform=platform_name, target=f"matrix[{index}].artifact.path", expected="caller-provided artifact path", status="blocked", remediation="提供本轮 APK/IPA,不要让 preflight 猜产物")
ok = False
if not isinstance(artifact, dict) or not isinstance(artifact.get(identity_key), str) or not artifact[identity_key].strip():
evidence.add(f"{prefix}-identity", platform=platform_name, target=f"matrix[{index}].artifact.{identity_key}", expected="declared artifact identity", status="blocked", remediation=f"声明 artifact.{identity_key}")
ok = False
if isinstance(artifact, dict) and artifact.get("sha256") is not None and not SHA256.fullmatch(text(artifact.get("sha256"))):
evidence.add(f"{prefix}-sha256", platform=platform_name, target=f"matrix[{index}].artifact.sha256", expected="64 hex characters", status="blocked", remediation="提供正确 SHA-256 或删除字段")
ok = False
if isinstance(artifact, dict) and artifact.get("source_commit") is not None and not COMMIT.fullmatch(text(artifact.get("source_commit"))):
evidence.add(f"{prefix}-commit", platform=platform_name, target=f"matrix[{index}].artifact.source_commit", expected="40-character commit SHA", status="blocked", remediation="提供产物来源 commit SHA")
ok = False
return ok
def artifact_identity(path: Path, platform_name: str, artifact: Mapping[str, Any]) -> tuple[str | None, str]:
if platform_name == "ios":
try:
with zipfile.ZipFile(path) as archive:
names = [name for name in archive.namelist() if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)]
if len(names) != 1:
return None, "IPA 中没有唯一 Payload/*.app/Info.plist"
plist = plistlib.loads(archive.read(names[0]))
value = plist.get("CFBundleIdentifier")
return (value, "Info.plist CFBundleIdentifier") if isinstance(value, str) else (None, "Info.plist 缺少 CFBundleIdentifier")
except (OSError, zipfile.BadZipFile, plistlib.InvalidFileException) as exc:
return None, f"IPA 读取失败: {exc}"
command = argv(artifact.get("identity_command"))
if command:
command = [item.replace("{artifact}", str(path)) for item in command]
else:
tool = shutil.which("apkanalyzer") or shutil.which("aapt2") or shutil.which("aapt")
if not tool:
return None, "缺少 apkanalyzer/aapt2/aapt;无法读取 APK package identity"
command = [tool, "manifest", "application-id", str(path)] if tool.endswith("apkanalyzer") else [tool, "dump", "badging", str(path)]
code, output = command_text(command)
if code != 0:
return None, output[-500:] or f"identity command exited {code}"
match = re.search(r"(?:application-id|name)\s*[:=]\s*['\"]?([A-Za-z0-9_.]+)", output)
if not match:
match = re.search(r"package:\s+name='([^']+)'", output)
if not match:
match = re.fullmatch(r"\s*([A-Za-z0-9_.]+)\s*", output)
return (match.group(1), output[-500:]) if match else (None, "identity command output did not contain package")
def record_artifact(evidence: Evidence, row: Mapping[str, Any], platform_name: str, node: str, index: int) -> bool:
artifact = row.get("artifact") or {}
path = Path(text(artifact.get("path"))).expanduser()
prefix = f"{platform_name}-{index}-artifact"
if not path.is_file():
evidence.add(prefix, platform=platform_name, node=node, target=str(path), expected="existing caller-provided artifact", status="blocked", remediation="补齐 APK/IPA 路径;不要默认构建")
return False
actual_hash = sha256(path)
expected_hash = artifact.get("sha256")
if expected_hash and actual_hash.lower() != text(expected_hash).lower():
evidence.add(prefix + "-hash", platform=platform_name, node=node, target=str(path), observed=actual_hash, expected=expected_hash, status="blocked", remediation="确认产物路径或 SHA-256 与本轮构建身份一致")
return False
evidence.add(prefix + "-hash", platform=platform_name, node=node, target=str(path), observed=actual_hash, expected=expected_hash or "file SHA-256 recorded", status="ready")
identity, observed = artifact_identity(path, platform_name, artifact)
expected_identity = text(artifact.get("package" if platform_name == "android" else "bundle_id"))
if identity != expected_identity:
evidence.add(prefix + "-identity", platform=platform_name, node=node, target=str(path), observed=identity or observed, expected=expected_identity, status="environment-fail", remediation="使用与 catalog 身份一致的 APK/IPA,并重新校验产物")
return False
evidence.add(prefix + "-identity", platform=platform_name, node=node, target=str(path), observed=identity, expected=expected_identity, status="ready")
if artifact.get("source_commit"):
evidence.add(prefix + "-commit", platform=platform_name, node=node, target=str(path), observed=artifact.get("source_commit"), expected=artifact.get("source_commit"), status="ready")
return True
def local_platform_probe(platform_name: str) -> tuple[str, dict[str, dict[str, Any]]]:
if platform_name == "android":
adb = shutil.which("adb")
if not adb:
return "environment-fail", {"adb": {"status": "environment-fail", "observed": "missing", "expected": "Android SDK platform-tools", "remediation": "在 tester.android 节点安装并配置 adb"}}
code, output = command_text([adb, "devices", "-l"])
states = [line.split() for line in output.splitlines()[1:] if line.strip()]
device = next((parts[0] for parts in states if len(parts) > 1 and parts[1] == "device"), "")
bad = next((parts for parts in states if len(parts) > 1 and parts[1] in {"unauthorized", "offline"}), None)
if bad:
return "environment-fail", {"adb": {"status": "ready", "observed": output, "expected": "adb responds"}, "device": {"status": "environment-fail", "observed": " ".join(bad), "expected": "authorized device", "remediation": "解锁 Android 设备并接受 RSA 授权;offline 时重启 adb/设备"}}
if code != 0 or not device:
return "environment-fail", {"adb": {"status": "environment-fail", "observed": output, "expected": "one authorized device", "remediation": "连接并授权 Android device/emulator"}}
boot_code, boot = command_text([adb, "-s", device, "shell", "getprop", "sys.boot_completed"])
booted = boot_code == 0 and boot.strip() == "1"
return ("ready" if booted else "environment-fail", {
"adb": {"status": "ready", "observed": adb, "expected": "adb available"},
"device": {"status": "ready", "observed": device, "expected": "authorized device"},
"boot": {"status": "ready" if booted else "environment-fail", "observed": boot, "expected": "sys.boot_completed=1", "remediation": "等待 emulator boot complete 后重跑"},
})
if host_platform.system() != "Darwin":
return "environment-fail", {"macos": {"status": "environment-fail", "observed": host_platform.system(), "expected": "Darwin/macOS tester.ios node", "remediation": "将 tester.ios 指向可用 macOS,不要在 Linux 伪装 iOS"}}
xcrun = shutil.which("xcrun")
if not xcrun:
return "environment-fail", {"xcrun": {"status": "environment-fail", "observed": "missing", "expected": "xcrun and simctl", "remediation": "在 Mac 安装 Xcode Command Line Tools/Xcode"}}
code, output = command_text([xcrun, "simctl", "list", "devices", "available"])
booted = any("(Booted)" in line for line in output.splitlines())
return ("ready" if code == 0 and booted else "environment-fail", {
"macos": {"status": "ready", "observed": "Darwin", "expected": "Darwin/macOS"},
"xcrun": {"status": "ready" if code == 0 else "environment-fail", "observed": output[-500:], "expected": "simctl available", "remediation": "安装 Xcode 并接受许可"},
"boot": {"status": "ready" if booted else "environment-fail", "observed": "Booted" if booted else output[-300:], "expected": "Booted simulator", "remediation": "启动目标 Simulator 后重跑"},
})
def runner_call(runner: Mapping[str, Any], request: Mapping[str, Any]) -> tuple[str, str, dict[str, Any]]:
command = argv(runner.get("command"))
if not command:
return "blocked", "runner command missing", {}
code, output = command_text(command, payload=request)
if code != 0:
return "environment-fail", output[-800:], {}
try:
result = json.loads(output)
except json.JSONDecodeError:
return "environment-fail", output[-800:], {}
if not isinstance(result, dict):
return "environment-fail", "runner returned non-object", {}
return status_from(result.get("status")), text(result.get("observed", "")), result
def add_runner_checks(evidence: Evidence, result: Mapping[str, Any], *, prefix: str, platform_name: str, node: str, default_expected: str) -> str:
checks = result.get("checks")
if isinstance(checks, dict) and checks:
statuses: list[str] = []
for name, detail in checks.items():
detail = detail if isinstance(detail, dict) else {"observed": detail}
status = status_from(detail.get("status"), status_from(result.get("status")))
statuses.append(status)
evidence.add(f"{prefix}-{name}", platform=platform_name, node=node, target=text(detail.get("target", name)), observed=detail.get("observed", result.get("observed", "")), expected=detail.get("expected", default_expected), status=status, remediation=text(detail.get("remediation", "")))
return "environment-fail" if "environment-fail" in statuses else ("blocked" if "blocked" in statuses else "ready")
status = status_from(result.get("status"))
evidence.add(prefix, platform=platform_name, node=node, target=prefix, observed=result.get("observed", ""), expected=default_expected, status=status, remediation=text(result.get("remediation", "")))
return status
def require_runner_checks(evidence: Evidence, result: Mapping[str, Any], *, prefix: str, platform_name: str, node: str, required: set[str]) -> bool:
checks = result.get("checks")
missing = sorted(required - set(checks)) if isinstance(checks, dict) else sorted(required)
if not missing:
return True
evidence.add(prefix + "-contract", platform=platform_name, node=node, target="runner response", observed={"missing": missing}, expected=sorted(required), status="blocked", remediation="让 tester 节点 adapter 返回每个必需的 adb/boot、macOS/xcrun/simctl 或 DNS/TCP/HTTP 证据")
return False
def network_local(url: str) -> list[dict[str, Any]]:
parsed = urllib.parse.urlsplit(url)
host = parsed.hostname or ""
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError as exc:
return [{"name": "dns", "status": "environment-fail", "observed": str(exc), "expected": "resolvable hostname", "remediation": "检查 tester 节点 DNS/VPN/网络"}]
rows: list[dict[str, Any]] = [{"name": "dns", "status": "ready", "observed": sorted({item[4][0] for item in addresses}), "expected": "resolvable hostname"}]
try:
with socket.create_connection((host, port), timeout=5):
rows.append({"name": "tcp", "status": "ready", "observed": f"{host}:{port}", "expected": "TCP connection"})
except OSError as exc:
rows.append({"name": "tcp", "status": "environment-fail", "observed": str(exc), "expected": f"TCP {host}:{port}", "remediation": "检查端口、防火墙和测试节点到 HOST 的路由"})
return rows
try:
with urllib.request.urlopen(urllib.request.Request(url, method="GET"), timeout=5) as response:
code = response.status
rows.append({"name": "http", "status": "ready" if 200 <= code < 500 else "environment-fail", "observed": code, "expected": "HTTP 2xx-4xx", "remediation": "检查后端健康状态" if code >= 500 else ""})
except urllib.error.HTTPError as exc:
rows.append({"name": "http", "status": "ready" if 200 <= exc.code < 500 else "environment-fail", "observed": exc.code, "expected": "HTTP 2xx-4xx", "remediation": "检查后端服务,不把 5xx 当成可用" if exc.code >= 500 else ""})
except (OSError, urllib.error.URLError) as exc:
rows.append({"name": "http", "status": "environment-fail", "observed": str(exc), "expected": "HTTP response", "remediation": "检查服务监听、代理、TLS 和节点网络"})
return rows
def check_network(evidence: Evidence, config: Mapping[str, Any], row: Mapping[str, Any], platform_name: str, node: str, index: int) -> bool:
tester = row.get("tester") or {}
runner = tester.get("runner") if isinstance(tester, dict) else None
is_local = node in {"local", "localhost", "127.0.0.1"}
statuses: list[str] = []
for probe_index, probe in enumerate(config.get("service_probes") or []):
raw_url = text(probe.get("url"))
if "{PUBLIC_HOST}" in raw_url and not text(config.get("public_host")).strip():
evidence.add(f"{platform_name}-{index}-network-{probe_index}-public-host", platform=platform_name, node=node, target=raw_url, expected="PUBLIC_HOST configured", status="blocked", remediation="提供 PUBLIC_HOST 或改用 HOST 探针")
statuses.append("blocked")
continue
url = normalize_url(expand(raw_url, config))
if not urllib.parse.urlsplit(url).hostname:
evidence.add(f"{platform_name}-{index}-network-{probe_index}-url", platform=platform_name, node=node, target=url, expected="valid probe URL", status="blocked", remediation="修正 service_probes URL 或 HOST/PUBLIC_HOST")
statuses.append("blocked")
continue
if is_local and (not isinstance(runner, dict) or runner.get("kind", "local") == "local"):
details = network_local(url)
expected_statuses = probe.get("expected_status")
for detail in details:
if detail["name"] == "http" and isinstance(expected_statuses, list) and detail["status"] == "ready" and detail["observed"] not in expected_statuses:
detail = {**detail, "status": "environment-fail", "expected": expected_statuses, "remediation": "检查服务探针的 expected_status 或后端响应"}
status = status_from(detail["status"])
statuses.append(status)
evidence.add(f"{platform_name}-{index}-network-{probe_index}-{detail['name']}", platform=platform_name, node=node, target=url, observed=detail["observed"], expected=detail["expected"], status=status, remediation=detail.get("remediation", ""))
elif isinstance(runner, dict) and runner.get("kind") == "contract":
binding = text(tester.get("binding")) if isinstance(tester, dict) else ""
status, observed, result = runner_call(runner, {"operation": "network_probe", "platform": platform_name, "node": node, "binding": binding, "url": url, "probe": probe})
result = result or {"status": status, "observed": observed}
complete = require_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-network-{probe_index}", platform_name=platform_name, node=node, required={"dns", "tcp", "http"})
network_status = add_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-network-{probe_index}", platform_name=platform_name, node=node, default_expected="DNS/TCP/HTTP reachable from actual tester node")
statuses.append(network_status if complete else "blocked")
else:
evidence.add(f"{platform_name}-{index}-network-{probe_index}", platform=platform_name, node=node, target=url, expected="node runner contract", status="blocked", remediation="远端 tester 必须提供 agent-runtime adapter runner")
statuses.append("blocked")
return "environment-fail" not in statuses and "blocked" not in statuses
def check_fixture_cleanup(evidence: Evidence, config: Mapping[str, Any], platform_name: str, node: str, index: int) -> bool:
ok = True
fixture = config.get("fixtures")
valid_fixture = isinstance(fixture, dict) and (not fixture.get("required", True) or bool(fixture.get("references")))
if not valid_fixture:
evidence.add(f"{platform_name}-{index}-fixture", platform=platform_name, node=node, target="fixtures", expected="required fixture references", status="blocked", remediation="声明隔离测试账号/fixture;不要在运行中临时创建")
ok = False
else:
evidence.add(f"{platform_name}-{index}-fixture", platform=platform_name, node=node, target="fixtures", observed=fixture.get("references"), expected="declared fixture references", status="ready")
cleanup = config.get("cleanup")
valid_noop = isinstance(cleanup, dict) and cleanup.get("required") is False and bool(text(cleanup.get("reason")).strip())
valid_steps = isinstance(cleanup, dict) and (bool(cleanup.get("steps")) or bool(text(cleanup.get("run")).strip()))
if not (valid_noop or valid_steps):
evidence.add(f"{platform_name}-{index}-cleanup", platform=platform_name, node=node, target="cleanup", expected="steps/run or explicit read-only reason", status="blocked", remediation="声明 teardown/rollback;清理缺失时禁止开始测试")
ok = False
else:
evidence.add(f"{platform_name}-{index}-cleanup", platform=platform_name, node=node, target="cleanup", observed=cleanup, expected="cleanup contract declared", status="ready")
return ok
def run(config: Mapping[str, Any], mode: str) -> Evidence:
evidence = Evidence(mode)
shape_ok = validate_shape(config, evidence)
deployment_ok = True
if mode == "prepare":
deployment = config.get("deployment")
environment = deployment.get("environment") if isinstance(deployment, dict) else None
command = argv(deployment.get("command")) if isinstance(deployment, dict) else None
if environment != "test":
evidence.add("deployment-environment", target="deployment.environment", observed=environment, expected="test", status="blocked", remediation="prepare 只允许 deployer 的 test 环境")
if not command:
evidence.add("deployment-command", target="deployment.command", expected="existing deployer adapter command", status="blocked", remediation="提供既有 deployer 的 JSON command contract;不要在 skill 内实现部署")
deployment_ok = False
elif not command:
evidence.add("deployment-command", target="deployment.command", expected="existing deployer adapter command", status="blocked", remediation="提供既有 deployer 的 JSON command contract;不要在 skill 内实现部署")
deployment_ok = False
else:
code, output = command_text(command, payload={"operation": "prepare", "environment": "test", "project_root": config.get("project_root"), "host": config.get("host"), "public_host": config.get("public_host")})
if code != 0:
evidence.add("deployment", target="test", observed=output[-800:], expected="deployer receipt status=ready", status="environment-fail", remediation="按 deployer 返回证据修复测试后端,再重跑 prepare")
deployment_ok = False
else:
try:
receipt = json.loads(output)
except json.JSONDecodeError:
receipt = {}
status = status_from(receipt.get("status"), "environment-fail") if isinstance(receipt, dict) else "environment-fail"
evidence.add("deployment", target="test", observed=receipt.get("observed", output[-800:]) if isinstance(receipt, dict) else output[-800:], expected="deployer receipt status=ready", status=status, remediation="检查既有 deployer 的测试环境 receipt" if status != "ready" else "")
deployment_ok = status == "ready"
if not shape_ok:
return evidence
for index, row in enumerate(config["matrix"]):
platform_name = text(row.get("platform")).lower()
tester = row.get("tester") or {}
node = text(tester.get("node"))
binding = text(tester.get("binding"))
evidence.add(f"{platform_name}-{index}-binding", platform=platform_name, node=node, target=binding, observed=binding, expected="resolved tester.<name> binding", status="ready")
artifact_ok = record_artifact(evidence, row, platform_name, node, index)
fixture_ok = check_fixture_cleanup(evidence, config, platform_name, node, index)
runner = tester.get("runner") if isinstance(tester, dict) else None
is_local = node in {"local", "localhost", "127.0.0.1"}
if is_local and (not isinstance(runner, dict) or runner.get("kind", "local") == "local"):
platform_status, details = local_platform_probe(platform_name)
for name, detail in details.items():
evidence.add(f"{platform_name}-{index}-platform-{name}", platform=platform_name, node=node, target=name, observed=detail.get("observed", ""), expected=detail.get("expected", "platform ready"), status=status_from(detail.get("status"), platform_status), remediation=detail.get("remediation", ""))
platform_ok = platform_status == "ready"
elif isinstance(runner, dict) and runner.get("kind") == "contract":
status, observed, result = runner_call(runner, {"operation": "platform_probe", "platform": platform_name, "node": node, "binding": binding})
result = result or {"status": status, "observed": observed}
required = {"adb", "device", "boot"} if platform_name == "android" else {"macos", "xcrun", "simctl", "boot"}
complete = require_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-platform", platform_name=platform_name, node=node, required=required)
platform_status = add_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-platform", platform_name=platform_name, node=node, default_expected="platform/device ready")
platform_ok = complete and platform_status == "ready"
else:
evidence.add(f"{platform_name}-{index}-platform-runner", platform=platform_name, node=node, target=node, expected="node runner contract", status="blocked", remediation="远端 tester 必须由 agent-runtime 提供可执行 runner")
platform_ok = False
network_ok = check_network(evidence, config, row, platform_name, node, index)
# This isolated project assumes the client capabilities used by its
# journeys. Their real behavior is a product assertion, not a preflight
# environment gate.
row_ok = artifact_ok and fixture_ok and platform_ok and network_ok
if mode == "prepare":
artifact = row.get("artifact") or {}
identity = artifact.get("package" if platform_name == "android" else "bundle_id")
target_path = text(artifact.get("remote_path") or artifact.get("path"))
if not row_ok or not deployment_ok:
evidence.add(f"{platform_name}-{index}-install", platform=platform_name, node=node, target=target_path, observed="not-run", expected="verify and deployment ready", status="blocked", remediation="先修复前置检查;prepare 不伪造安装成功")
evidence.add(f"{platform_name}-{index}-start", platform=platform_name, node=node, target=text(identity), observed="not-run", expected="install ready", status="blocked", remediation="安装前置失败,未启动 app")
else:
install_request = {"operation": "install", "platform": platform_name, "node": node, "artifact": target_path, "identity": identity}
start_request = {"operation": "start", "platform": platform_name, "node": node, "identity": identity}
if is_local and (not isinstance(runner, dict) or runner.get("kind", "local") == "local"):
if platform_name == "android":
adb = shutil.which("adb") or "adb"
serial = os.environ.get("ANDROID_SERIAL", "")
install_code, install_output = command_text([adb, "-s", serial, "install", "-r", target_path] if serial else [adb, "install", "-r", target_path])
install_result = {"status": "ready" if install_code == 0 else "environment-fail", "observed": install_output}
launch = [adb, "-s", serial, "shell", "monkey", "-p", text(identity), "1"] if serial else [adb, "shell", "monkey", "-p", text(identity), "1"]
start_code, start_output = command_text(launch) if install_code == 0 else (1, "install failed")
start_result = {"status": "ready" if start_code == 0 else "environment-fail", "observed": start_output}
else:
install_result = {"status": "environment-fail", "observed": "local iOS install requires a macOS runner", "remediation": "use tester.ios contract runner on Mac"}
start_result = {"status": "blocked", "observed": "not-run", "remediation": "install failed"}
add_runner_checks(evidence, install_result, prefix=f"{platform_name}-{index}-install", platform_name=platform_name, node=node, default_expected="artifact installed")
add_runner_checks(evidence, start_result, prefix=f"{platform_name}-{index}-start", platform_name=platform_name, node=node, default_expected="app launched")
elif isinstance(runner, dict) and runner.get("kind") == "contract":
install_status, install_observed, install_result = runner_call(runner, install_request)
add_runner_checks(evidence, install_result or {"status": install_status, "observed": install_observed}, prefix=f"{platform_name}-{index}-install", platform_name=platform_name, node=node, default_expected="artifact installed")
if install_status == "ready":
start_status, start_observed, start_result = runner_call(runner, start_request)
add_runner_checks(evidence, start_result or {"status": start_status, "observed": start_observed}, prefix=f"{platform_name}-{index}-start", platform_name=platform_name, node=node, default_expected="app launched")
else:
evidence.add(f"{platform_name}-{index}-start", platform=platform_name, node=node, target=text(identity), observed="not-run", expected="install ready", status="blocked", remediation="安装失败,先修复 tester 节点")
return evidence
def write_evidence(evidence: Evidence, yaml_path: Path, csv_path: Path) -> dict[str, Any]:
payload = evidence.payload()
yaml_path.parent.mkdir(parents=True, exist_ok=True)
csv_path.parent.mkdir(parents=True, exist_ok=True)
yaml_path.write_text(yaml.safe_dump(payload, allow_unicode=True, sort_keys=False), encoding="utf-8")
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=EVIDENCE_FIELDS)
writer.writeheader()
writer.writerows(evidence.rows)
return {"status": payload["status"], "yaml": str(yaml_path), "csv": str(csv_path), "checks": len(evidence.rows), "summary": payload["summary"]}
def main() -> int:
parser = argparse.ArgumentParser(description="Verify or prepare a test environment and emit YAML/CSV evidence")
parser.add_argument("mode", choices=("verify", "prepare"))
parser.add_argument("--config", required=True, type=Path)
parser.add_argument("--yaml", dest="yaml_path", type=Path, default=Path("preflight.yaml"))
parser.add_argument("--csv", dest="csv_path", type=Path, default=Path("preflight.csv"))
args = parser.parse_args()
try:
config = load_config(args.config)
evidence = run(config, args.mode)
result = write_evidence(evidence, args.yaml_path, args.csv_path)
except (OSError, ValueError, yaml.YAMLError) as exc:
print(json.dumps({"status": "blocked", "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["status"] == "ready" else 1
if __name__ == "__main__":
raise SystemExit(main())
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Deterministic runner fixture for test-preflight contract tests."""
import json
import os
import sys
request = json.load(sys.stdin)
scenario = os.environ.get("PREFLIGHT_RUNNER_SCENARIO", "ready")
operation = request.get("operation")
if operation in {"deployment", "prepare"}:
print(json.dumps({"status": "ready", "observed": "mock test deploy receipt"}))
elif operation == "platform_probe":
platform = request.get("platform")
if scenario == "android-unauthorized" and platform == "android":
print(json.dumps({"status": "environment-fail", "checks": {
"adb": {"status": "ready", "observed": "adb 35", "expected": "adb responds"},
"device": {"status": "environment-fail", "observed": "emulator-5554 unauthorized", "expected": "authorized device", "remediation": "accept RSA authorization"},
"boot": {"status": "environment-fail", "observed": "unknown", "expected": "boot complete"},
}}))
elif scenario == "mac-unavailable" and platform == "ios":
print(json.dumps({"status": "environment-fail", "checks": {
"macos": {"status": "environment-fail", "observed": "Mac unavailable", "expected": "Darwin tester node", "remediation": "restore tester.ios Mac"},
}}))
else:
print(json.dumps({"status": "ready", "checks": {
"adb": {"status": "ready", "observed": "adb 35", "expected": "adb responds"},
"device": {"status": "ready", "observed": "authorized", "expected": "authorized device"},
"boot": {"status": "ready", "observed": "boot complete", "expected": "boot complete"},
"macos": {"status": "ready", "observed": "Darwin", "expected": "Darwin"},
"xcrun": {"status": "ready", "observed": "xcrun", "expected": "xcrun"},
"simctl": {"status": "ready", "observed": "available", "expected": "simctl"},
}}))
elif operation == "network_probe":
if scenario == "host-unreachable":
print(json.dumps({"status": "environment-fail", "checks": {
"dns": {"status": "ready", "observed": "10.0.0.2", "expected": "DNS resolves"},
"tcp": {"status": "environment-fail", "observed": "connection refused", "expected": "TCP reachable", "remediation": "check HOST route"},
}}))
elif scenario == "http-failure":
print(json.dumps({"status": "environment-fail", "checks": {
"dns": {"status": "ready", "observed": "10.0.0.2", "expected": "DNS resolves"},
"tcp": {"status": "ready", "observed": "443 open", "expected": "TCP reachable"},
"http": {"status": "environment-fail", "observed": 503, "expected": "HTTP 2xx-4xx", "remediation": "check service health"},
}}))
else:
print(json.dumps({"status": "ready", "checks": {
"dns": {"status": "ready", "observed": "10.0.0.2", "expected": "DNS resolves"},
"tcp": {"status": "ready", "observed": "443 open", "expected": "TCP reachable"},
"http": {"status": "ready", "observed": 200, "expected": "HTTP 2xx-4xx"},
}}))
elif operation == "capability_probe":
capability = request.get("capability")
checks = {
"album_picker": {
"picker": {"status": "ready", "observed": "real picker opened and returned image-001"},
},
"photo_permission": {
"permission": {"status": "ready", "observed": "permission state checked on device"},
"test_image": {"status": "ready", "observed": "image-001 available in test album"},
},
"ime_pinyin": {
"composition": {"status": "ready", "observed": "nihao composed in Chinese IME"},
"candidates": {"status": "ready", "observed": "你好 candidate selected"},
},
}
if scenario == "capability-missing" and capability == "album_picker":
print(json.dumps({"status": "ready", "checks": {}}))
else:
print(json.dumps({"status": "ready", "checks": checks.get(capability, {})}))
elif operation in {"install", "start"}:
print(json.dumps({"status": "ready", "observed": operation + "ed mock artifact"}))
else:
print(json.dumps({"status": "environment-fail", "observed": "unknown operation"}))
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import plistlib
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import preflight # noqa: E402
FIXTURE_RUNNER = Path(__file__).resolve().parent / "fixtures" / "contract_runner.py"
class PreflightTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.apk = self.root / "writes.apk"
self.apk.write_bytes(b"mock apk")
self.ipa = self.root / "writes.ipa"
with zipfile.ZipFile(self.ipa, "w") as archive:
archive.writestr("Payload/Writes.app/Info.plist", plistlib.dumps({"CFBundleIdentifier": "page.writes"}))
self.identity = self.root / "identity.py"
self.identity.write_text("print('page.writes')\n", encoding="utf-8")
def tearDown(self) -> None:
self.temp.cleanup()
def runner(self, scenario: str = "ready") -> dict[str, object]:
return {"kind": "contract", "command": [sys.executable, str(FIXTURE_RUNNER)]}
def config(self, *, platform: str = "android", artifact: Path | None = None, node: str = "tester.android", scenario: str = "ready") -> dict[str, object]:
artifact = artifact or self.apk
artifact_row: dict[str, object] = {
"path": str(artifact),
"package": "page.writes" if platform == "android" else None,
"bundle_id": "page.writes" if platform == "ios" else None,
}
if platform == "android":
artifact_row["identity_command"] = [sys.executable, str(self.identity)]
artifact_row.pop("bundle_id")
else:
artifact_row.pop("package")
return {
"schema_version": 1,
"project_root": str(self.root),
"host": "https://test.example.invalid",
"public_host": "https://public.example.invalid",
"service_probes": [{"id": "health", "url": "{HOST}/health", "expected_status": [200]}],
"fixtures": {"required": True, "references": ["isolated-account"]},
"cleanup": {"required": True, "steps": ["cleanup-run-data"]},
"matrix": [{
"platform": platform,
"tester": {"binding": "tester.android" if platform == "android" else "tester.ios", "node": node, "runner": self.runner(scenario)},
"artifact": artifact_row,
}],
}
def write_config(self, data: dict[str, object]) -> Path:
path = self.root / "matrix.yaml"
import yaml
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
return path
def invoke(self, mode: str, data: dict[str, object]) -> tuple[int, dict[str, object], dict[str, object]]:
config = self.write_config(data)
yaml_path, csv_path = self.root / f"{mode}.yaml", self.root / f"{mode}.csv"
env = os.environ.copy()
env["PREFLIGHT_RUNNER_SCENARIO"] = "ready"
process = subprocess.run([
sys.executable, str(ROOT / "scripts" / "preflight.py"), mode,
"--config", str(config), "--yaml", str(yaml_path), "--csv", str(csv_path),
], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
import yaml
return process.returncode, yaml.safe_load(yaml_path.read_text(encoding="utf-8")), {"stdout": process.stdout, "stderr": process.stderr}
def test_missing_artifact_is_blocked_and_report_has_contract_columns(self):
data = self.config(artifact=self.root / "missing.apk")
code, report, _ = self.invoke("verify", data)
self.assertEqual(code, 1)
self.assertEqual(report["status"], "blocked")
row = next(row for row in report["checks"] if row["check_id"] == "android-0-artifact")
self.assertEqual(row["status"], "blocked")
self.assertEqual(set(row), set(preflight.EVIDENCE_FIELDS))
def test_android_unauthorized_is_environment_fail(self):
data = self.config()
config = self.write_config(data)
out = self.root / "unauthorized.yaml"
env = os.environ.copy()
env["PREFLIGHT_RUNNER_SCENARIO"] = "android-unauthorized"
process = subprocess.run([
sys.executable, str(ROOT / "scripts" / "preflight.py"), "verify", "--config", str(config), "--yaml", str(out), "--csv", str(self.root / "u.csv")
], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
report = json.loads(json.dumps(__import__("yaml").safe_load(out.read_text(encoding="utf-8"))))
self.assertEqual(process.returncode, 1)
self.assertEqual(report["status"], "environment-fail")
self.assertEqual(next(row for row in report["checks"] if row["check_id"].endswith("-device"))["status"], "environment-fail")
def test_linux_without_xcrun_is_environment_fail(self):
data = self.config(platform="ios", artifact=self.ipa, node="local")
data["matrix"][0]["tester"]["runner"] = {"kind": "local"}
with mock.patch.object(preflight.host_platform, "system", return_value="Linux"), mock.patch.object(preflight, "network_local", return_value=[{"name": "dns", "status": "ready", "observed": "ok", "expected": "DNS"}, {"name": "tcp", "status": "ready", "observed": "ok", "expected": "TCP"}, {"name": "http", "status": "ready", "observed": 200, "expected": "HTTP"}]):
result = preflight.run(data, "verify")
self.assertEqual(result.overall(), "environment-fail")
row = next(row for row in result.rows if row["check_id"].endswith("-platform-macos"))
self.assertEqual(row["status"], "environment-fail")
def test_mac_unavailable_contract_is_environment_fail(self):
data = self.config(platform="ios", artifact=self.ipa)
config_path = self.write_config(data)
env = os.environ.copy()
env["PREFLIGHT_RUNNER_SCENARIO"] = "mac-unavailable"
output = self.root / "mac.yaml"
process = subprocess.run([
sys.executable, str(ROOT / "scripts" / "preflight.py"), "verify", "--config", str(config_path), "--yaml", str(output), "--csv", str(self.root / "mac.csv")
], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
import yaml
report = yaml.safe_load(output.read_text(encoding="utf-8"))
self.assertEqual(process.returncode, 1)
self.assertEqual(report["status"], "environment-fail")
self.assertTrue(any(row["check_id"].endswith("-macos") and row["status"] == "environment-fail" for row in report["checks"]))
def test_missing_fixture_or_cleanup_is_blocked(self):
data = self.config()
data["fixtures"] = {"required": True, "references": []}
data["cleanup"] = {"required": True, "steps": []}
result = preflight.run(data, "verify")
self.assertEqual(result.overall(), "blocked")
self.assertTrue(any(row["check_id"].endswith("-fixture") and row["status"] == "blocked" for row in result.rows))
self.assertTrue(any(row["check_id"].endswith("-cleanup") and row["status"] == "blocked" for row in result.rows))
def test_mobile_capabilities_are_assumed_and_not_a_preflight_gate(self):
data = self.config()
config_path = self.write_config(data)
env = os.environ.copy()
env["PREFLIGHT_RUNNER_SCENARIO"] = "capability-missing"
output = self.root / "capabilities.yaml"
process = subprocess.run([
sys.executable, str(ROOT / "scripts" / "preflight.py"), "verify",
"--config", str(config_path), "--yaml", str(output), "--csv", str(self.root / "capabilities.csv"),
], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
report = __import__("yaml").safe_load(output.read_text(encoding="utf-8"))
self.assertEqual(process.returncode, 0)
self.assertEqual(report["status"], "ready")
self.assertFalse(any("capability-" in row["check_id"] for row in report["checks"]))
def test_host_unreachable_from_actual_runner_is_environment_fail(self):
data = self.config()
config_path = self.write_config(data)
env = os.environ.copy()
env["PREFLIGHT_RUNNER_SCENARIO"] = "host-unreachable"
output = self.root / "host.yaml"
process = subprocess.run([
sys.executable, str(ROOT / "scripts" / "preflight.py"), "verify", "--config", str(config_path), "--yaml", str(output), "--csv", str(self.root / "host.csv")
], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
import yaml
report = yaml.safe_load(output.read_text(encoding="utf-8"))
self.assertEqual(process.returncode, 1)
self.assertEqual(report["status"], "environment-fail")
self.assertTrue(any("network" in row["check_id"] and row["status"] == "environment-fail" for row in report["checks"]))
def test_http_500_is_environment_fail_not_product_fail(self):
data = self.config()
config_path = self.write_config(data)
env = os.environ.copy()
env["PREFLIGHT_RUNNER_SCENARIO"] = "http-failure"
output = self.root / "http.yaml"
process = subprocess.run([
sys.executable, str(ROOT / "scripts" / "preflight.py"), "verify", "--config", str(config_path), "--yaml", str(output), "--csv", str(self.root / "http.csv")
], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
import yaml
report = yaml.safe_load(output.read_text(encoding="utf-8"))
self.assertEqual(process.returncode, 1)
http_row = next(row for row in report["checks"] if row["check_id"].endswith("-http"))
self.assertEqual(http_row["observed"], "503")
self.assertEqual(http_row["status"], "environment-fail")
def test_prepare_without_deployer_is_blocked_and_never_installs(self):
data = self.config()
code, report, _ = self.invoke("prepare", data)
self.assertEqual(code, 1)
self.assertEqual(report["status"], "blocked")
self.assertFalse(any(row["check_id"].endswith("-install") and row["status"] == "ready" for row in report["checks"]))
self.assertTrue(any(row["check_id"] == "deployment-command" for row in report["checks"]))
def test_prepare_mock_contract_calls_install_and_start(self):
data = self.config()
data["deployment"] = {"environment": "test", "command": [sys.executable, str(FIXTURE_RUNNER)]}
code, report, _ = self.invoke("prepare", data)
self.assertEqual(code, 0)
self.assertEqual(report["status"], "ready")
ids = {row["check_id"] for row in report["checks"]}
self.assertIn("android-0-install", ids)
self.assertIn("android-0-start", ids)
def test_verify_never_calls_install_or_start(self):
data = self.config()
calls: list[str] = []
original = preflight.runner_call
def record(runner, request):
calls.append(str(request["operation"]))
return original(runner, request)
with mock.patch.object(preflight, "runner_call", side_effect=record):
result = preflight.run(data, "verify")
self.assertEqual(result.overall(), "ready")
self.assertNotIn("install", calls)
self.assertNotIn("start", calls)
self.assertNotIn("capability_probe", calls)
if __name__ == "__main__":
unittest.main()
+87 -6
View File
@@ -8,24 +8,45 @@
- 「加一条相机开盖的测试」「完善 TC-CAM-003 的验证」
- 「跑 smoke」「测 CAM」「跑 TC-INF-001 和 TC-CAM-001」
- 「现场复测」
- 「用这个 APK/IPA 地址跑 Android+iOS」
不管 ACK。这是项目自己的测试目录。
## 使用前准备
- 目录在项目根的 `.pouch/tester/`。没有就说「初始化 tester」。
- 目录在目标测试项目根的 `.pouch/tester/`。父仓库和 `app` 等 Git submodule 可以各自有
catalog;先运行 `catalog.py roots --root <repo-root>` 发现它们,不要把两个项目的 cases 合并。
没有任何 catalog 才说「初始化 tester」。
- 从父仓库直接执行 tester 命令时,只有一个子 catalog 会自动选中;多个子 catalog 会自动
fan-out,逐个执行且不合并 cases。无需在根 `.pouch` 里增加未定义的 `include`。
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认
`HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量
`HX_TEST_HOST` 覆盖。
`HOST` 覆盖。
- 测试默认在当前项目执行。也可以直接提供 Orca project id、项目名称或 terminal id;
tester 会自动解析 Agent、环境、项目和目录,写入 `local.yaml.agent`,并输出更新后的配置列表。
每轮测试会创建新的 terminal,结束后自动关闭;不会复用或关闭已有 terminal。
- 测试环境部署由 deployer 在本机 Compose 中触发;正式环境部署由本机 `~/code/app00`
项目的 fresh Agent terminal 触发,tester 不直接 SSH 或执行正式 Compose。
- 会动硬件、会改数据的用例带风险级别(`readonly` / `mutating` / `destructive`)。默认只跑最低档 `readonly`。要跑更高风险,需要你明确说,例如「风险到 mutating」;碰哪个子系统另由 `gate`(如 hardware / weld)控制。
- 会动硬件、会改数据的用例带风险级别(`readonly` / `mutating` / `destructive`)。默认
`standard` 只跑最低档 `readonly`。隔离、可重置的 Writes 类环境在 catalog 设置
`execution_profile: isolated-full` 后,所有 active 自动化用例都进入执行,不因
`AUTHORIZE_MUTATING`、`AUTHORIZE_DESTRUCTIVE`、`AUTHORIZE_ALBUM` 或
`AUTHORIZE_PASSWORD` skipped;risk 原值仍保留。设备、产物、服务和 cleanup/rollback
由 test-preflight 保证;客户端相册、权限和中文输入能力默认具备,由 journey 用例验证。
## 使用示例
```bash
<skill-dir>/tester run \
--artifact android=<path-or-url> \
--artifact ios=<path-or-url> \
--platform android,ios
```
调用方必须给出每个平台的产物位置,可以是本地文件或下载地址。tester 按这个输入取用或下载
APK/IPA,为本轮创建隔离 worktree,在 Android 和 iOS tester 节点安装并验证身份,然后交给
真实移动 session 执行 catalog。默认全量闸门,只有显式 `--partial` 才允许单平台继续。
```text
列出测试用例
```
@@ -54,9 +75,14 @@
1. 用脚本读 `.pouch/tester/`,按分类统计或筛出你点名的用例,不会把整库塞进对话。
2. 改用例前先给你看将要写入的内容,你确认再保存。
3. 执行时:若用户要求准备环境,先按目标环境走 deployer 路由;再解析并更新 Agent 配置,输出生效列表,再准备 Orca 项目并创建临时 terminal;脚本类、
浏览器和移动模拟器都在该 Agent 环境中执行。
4. 超出你允许的风险级别会跳过,不会偷偷执行。
3. 执行时先建立“产物 × 平台 × tester 绑定 × 执行环境”矩阵,并把平台、产物和 binding
传给 `catalog.py select` 做硬性校验,再对矩阵全部行做
preflight。任一产物、平台或环境不可用,都先返回完整阻塞清单,不创建测试 session、
不执行可用平台;只有全部可用才按目标环境走 deployer 路由、解析 Agent、输出生效
列表、准备 Orca 项目并创建临时 terminal。脚本类、浏览器和移动模拟器都在对应
Agent 环境中执行。`builder.<platform>` 不能替代 `tester.<platform>`。
4. `standard` 下超出你允许的风险级别会跳过;`isolated-full` 由 catalog profile 明确
全量 active 执行,不把风险悄悄改成 readonly。
5. 无论测试成功、失败或异常,都会关闭本轮创建的 terminal;跑完给出按分类计数的
结果,以及 `pass / fail / environment-fail / skipped / not-run`。
@@ -65,3 +91,58 @@
- **列出**:能看到分类表和总条数;`.pouch/tester/cases.csv` 已由 skill 里的 `generate_cases_csv.py` 从 `cases/` 重新生成。每次运行的结果保存在独立的 `.pouch/tester/runs/<run-id>.*` 文件中。
- **维护**:目标 id 能 `查看` 到,且检查通过。
- **运行**:有本次 `runs/*.yaml` 快照和同名 `.csv` 成绩单(`executed` 列区分已跑 / 未跑);没填 HOST 时会停,而不是编造失败。
### 客户端用户旅程
测试用例支持两种顶层类型:`test_type: integration` 和 `test_type: journey`。集成套件
至少包括 `api-contract`、`authorization`、`build-contract`;客户端套件仍是
`client-smoke`、`client-regression`、`safety`。没有显式范围时默认不选择 integration。
执行顺序固定为 `test-preflight -> integration -> journey`。preflight 状态为 blocked 或
environment-fail 时不创建客户端 session;integration 失败时依赖 journey 为 `not-run`。
服务不可达是 environment-fail,契约断言错误是 integration fail,正确账号登录失败仍是
journey fail。`tester run`/bundle 必须传入 caller 提供的 matrix:
```bash
<skill-dir>/tester run --preflight-config /abs/preflight.yaml \
--artifact android=/abs/app.apk --artifact ios=/abs/app.ipa
```
没有真实 deployer、Mac 或设备时,只能使用 test-preflight 的 command-contract mock fixture
验证协议,不能把 mock 结果写成真实运行成功。
客户端 E2E 用例使用 `client_suite: client-smoke | client-regression | safety`,并明确
`journey`、`fixture`、`session`、`cleanup`、`platform`。标准生命周期是:
```text
preflight → 启动 → 登录 → 旅程 → 断言 → 清理 → 报告
```
正确测试账号登录失败记 `fail`;设备、产物或服务不可达记 `environment-fail`。fixture 或
cleanup 缺失会在开始前阻断;前置失败后的剩余用例是 `not-run`,明确排除才是 `skipped`。
`safety` 必须显式选择和授权,不进入默认 smoke/full。它与普通 tester 用例共用原有
CSV/YAML 运行记录和汇总校验。
移动 preflight 还必须在实际 tester 节点验证相册 picker、照片权限与测试图片,以及中文
拼音的真实 composition 和候选词选择;缺失时所有 testcase 都保留 `not-run` +
`blocked_by`,不会静默 `skipped`。
失败回流使用 run YAML 顶层 `defects`/`test_issues` 与 `feedback`:PR 触发先更新 PR/关联
issue、通知 dispatcher 重新分配开发;需求/bug 触发直接回开发流程。可用
`scripts/record_issues.py` 生成同名 CSV 的结构化 issue 列。
### 汇总已有运行结果
对已经生成的同一运行 CSV/YAML,使用显式路径汇总:
```bash
python3 <skill-dir>/scripts/summarize_run.py \
--csv .pouch/tester/runs/<run-id>.csv \
--yaml .pouch/tester/runs/<run-id>.yaml
```
脚本只从 CSV 的 `result` 列计算 `pass`、`fail`、`environment-fail`、`skipped`、
`not-run` 五类状态,并要求五类计数和等于 CSV 数据行数。YAML 只用于校验
`results` 数量和 testcase id 集合;数量、集合、重复/缺失 id 或 CSV 缺失/未知状态
时返回非零并输出诊断。校验成功才表示这份汇总可作为运行报告;非零表示报告不一致,
不能据此判定产品失败。
+95 -6
View File
@@ -11,7 +11,35 @@ description: >-
# Tester:项目测试目录
开始时解析本 `SKILL.md` 所在目录为 `<skill-dir>`。项目根用
`git rev-parse --show-toplevel`。目录固定为 `<root>/.pouch/tester/`。
`git rev-parse --show-toplevel`。目录固定为目标测试项目的 `<root>/.pouch/tester/`;父仓库
和 Git submodule 可以各自拥有独立目录。
## 多测试项目 / 子项目
一个仓库可能同时包含后端测试目录和 `app` 等子项目测试目录。用例属于拥有
`catalog.yaml` 的项目,不能把多个 catalog 的 cases 合并成一份清单或共用一次运行快照。
开始列出或运行前,先发现目标仓库下的独立测试根:
```bash
python3 <skill-dir>/scripts/catalog.py roots --root <repo-root>
```
选择规则:
- `<repo-root>/.pouch/tester/catalog.yaml` 存在时,它是一个独立测试根;
- 同时检查 Git submodule 和一层普通子项目目录;
- 只有一个嵌套 catalog 时,可直接用该路径继续 `list/select/run`;
- 有多个 catalog 时由 tester 自动 fan-out,逐个项目分别执行,不等待用户选择,也不合并 cases;
- 每个测试根单独生成 `.pouch/tester/runs/*.yaml`/`.csv`,交付记录可分别引用这些快照。
如果父仓库没有 catalog 但子项目有 catalog,不能把“父目录无 catalog”报告为环境失败;先用
`roots` 找到子项目并继续。只有发现不到任何 catalog,才按缺少 tester 目录处理。
CLI 也有同样的保护:从父仓库执行 `list/select/validate/run-script` 时,若只发现一个
子 tester 根会自动进入该根;发现多个候选会自动 fan-out,每个根独立输出、独立校验、
独立生成运行记录。不需要、也不能通过在根 `.pouch` 里写一个未定义的 `include` 来拼接
子项目配置。
Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.<name>` binding,由共享
`agent-runtime` 解析 environment、host、project、path 和 profile。`.pouch/tester/local.yaml`
@@ -21,7 +49,7 @@ Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.<name>`
用脚本读写目录,不要把 `cases/` 整库灌进上下文。术语与命名理由见 [references/concepts.md](references/concepts.md):
```bash
python3 <skill-dir>/scripts/catalog.py <list|select|show|validate|next-id|run-script> \
python3 <skill-dir>/scripts/catalog.py <roots|list|select|show|validate|next-id|run-script> \
--root <project-root> [参数]
```
@@ -34,7 +62,7 @@ tester agent 配置。
## 选择模式
- 还没有 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。
- 先用 `roots` 检查当前仓库及子项目;没有任何 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。
- 列出、有哪些测试、一共多少条:执行「列出」。
- 看某一条怎么测:执行「查看」。
- 加一条 / 完善 / 改验证 / 退役:执行「维护」。
@@ -46,10 +74,10 @@ tester agent 配置。
- `local.yaml.example` 的 HOST 默认是 `127.0.0.1`;如果目标不是本机,必须在
`local.yaml` 或环境变量中显式覆盖。`local.yaml` 不存在且没有环境变量时仍不猜
HOST,停下并记 environment-fail。
- 默认风险上限是 catalog 里的 `default_max_risk`。超出上限的条目标 `skipped`,禁止执行。
- 默认执行 profile 是 `standard`:风险上限来自 catalog 的 `default_max_risk`,超出上限的条目标 `skipped`,禁止执行。若 catalog 声明 `execution_profile: isolated-full`,只允许用于隔离且可重置的测试环境:所有 active 自动化用例都进入执行矩阵,不因 `AUTHORIZE_MUTATING`、`AUTHORIZE_DESTRUCTIVE`、`AUTHORIZE_ALBUM` 或 `AUTHORIZE_PASSWORD` skipped;原有 `risk` 值必须保留并进入报告。设备、产物、服务和清理契约由 test-preflight 检查;相册、照片权限/测试图片和中文拼音能力在本项目中默认具备,由 journey 用例真实验证。
- `risk` 只能是 `readonly` / `mutating` / `destructive`,默认只跑 `readonly`。`tag=smoke` 只能配 `risk=readonly`。规则见 [levels.md](references/levels.md)。
- `driver=browser` 必须开真实页面;`driver=mobile` 必须挂模拟器 session。二者都不得改成只打 API。
- `automation=assisted|manual` 需要人在场:无人值守时记 `not-run`,不要自己代跑人工步骤。见 [concepts.md](references/concepts.md)。
- `automation=assisted|manual` 需要人在场:无人值守时记 `not-run`,不要自己代跑人工步骤。isolated-full 取消的是风险/授权过滤,不会伪造人工操作。见 [concepts.md](references/concepts.md)。
- 浏览器、移动模拟器按 catalog `sessions` 初始化一次,同一次运行内复用。
- Agent、环境、项目目录和 Orca terminal 生命周期见 [execution.md](references/execution.md):可以从 project id/名称或 terminal id 自动解析,更新后输出配置列表;每轮都新建 terminal,结束后关闭本轮创建的 terminal,不复用或关闭已有 terminal。
- 环境准备见 [deployment.md](references/deployment.md):测试环境只调用 deployer 的本机 Compose 路径;正式环境只委派给本机 `~/code/app00` 的 fresh Agent terminal,tester 不直接 SSH 或执行生产 Compose。
@@ -109,4 +137,65 @@ python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
## 运行
读 [run.md](references/run.md)、[execution.md](references/execution.md) 和 [deployment.md](references/deployment.md)。若本轮要求准备环境,先按目标环境完成部署,再 `select` 得到命中列表;测试环境由本机 deployer 触发,正式环境由 app00 Agent terminal 委派。随后按 agent 解析目标项目、目录和 Agent,更新配置并输出生效列表,创建测试用临时 terminal 并按 driver 分流。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。移动端还要先识别目标平台并完成 Android 或 iOS 环境闸门;闸门未通过时输出对应的用户引导,不继续猜测或代跑。无论部署或测试成功、失败、超时还是中断,都要清理各自本轮创建的 terminal。
读 [run.md](references/run.md)、[execution.md](references/execution.md)、[deployment.md](references/deployment.md) 和 [test-bundle.md](references/test-bundle.md)。运行必须按以下顺序执行:
1. 先建立本轮测试矩阵,列出实际要测的产物、平台、commit、包/Bundle 身份、产物路径或下载地址,以及对应的 `tester.<platform>` 执行绑定。用户要求 Android+iOS 时,两行都必须出现;不能因为只拿到 Android 产物就缩小范围。
2. 对所有 tester 根的矩阵行做完整 preflight:先用 `catalog.py select` 校验平台、产物定位和 tester binding,再检查目标 Agent/环境/项目路径、Android/iOS 设备闸门和 HOST 可达。所有根的检查完成前不得创建测试 session 或开始任何测试用例。
3. 有任一根或任一行不可用时,先完成其它根的 preflight,再一次性输出完整不可用清单、观测结果、修复动作和可复制的验证命令,然后停止测试执行;不能先执行已通过 preflight 的根,也不能把缺失平台记成测试通过。
4. 只有矩阵所有行都可用,才创建本轮测试 terminal/session,按 `select` 结果执行并记录成绩。
测试环境由本机 deployer 触发,正式环境由 app00 Agent terminal 委派。随后按 agent 解析目标项目、目录和 Agent,更新配置并输出生效列表。移动端每个平台的产物位置必须由调用方给出,值为本地文件或 URL;缺了就停,不要猜 builder、CI release 或本机旧包。矩阵行记产物位置、身份和 `tester.<platform>` binding,并用 `<skill-dir>/tester run --artifact android=<path-or-url> --artifact ios=<path-or-url> --platform android,ios` 下载或取用、安装并准备本轮隔离 worktree。Android 在 `tester.android`、iOS 在 `tester.ios` 节点执行。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。无论部署或测试成功、失败、超时还是中断,都要清理各自本轮创建的 terminal。
### 客户端用户旅程标准
### 双层测试与执行顺序
用例顶层类型是 `test_type: integration | journey`。旧目录没有该字段时保持兼容:已有
`client_suite`/`journey` 视为 journey,已有 `integration_suite` 视为 integration。集成
套件至少包括 `api-contract`、`authorization`、`build-contract`;旅程套件仍固定为
`client-smoke`、`client-regression`、`safety`。集成用例不需要客户端 session、platform
或 journey 字段,不能被客户端套件选中。
一次运行严格遵守:
```text
test-preflight (blocked/environment-fail -> 不创建 session)
-> integration
-> journey (integration fail -> not-run,不创建 session)
```
`test-preflight` 的报告是环境证据,不计入 testcase 总数。服务不可达记
`environment-fail`;服务已达但契约断言错误记 integration `fail`;正确测试账号登录失败
仍记 journey `fail`。tester 的 `run`/bundle 入口必须接收 caller 提供的 preflight matrix,
没有 matrix 不得绕过闸门或伪造成功。
声明 `client_suite`(迁移期也接受 `suite`)的用例采用固定的三套客户端集合:
`client-smoke`、`client-regression`、`safety`。前两者分别覆盖最短只读主路径和隔离测试
环境中的普通用户回归;`safety` 覆盖危险/权限边界场景,必须显式选择和授权,不能被
默认 smoke/full 扩入。选择某套客户端旅程可用:
```bash
python3 <skill-dir>/scripts/catalog.py select --root <root> --suite client-smoke
python3 <skill-dir>/scripts/catalog.py select --root <root> --suite client-regression --max-risk mutating
python3 <skill-dir>/scripts/catalog.py select --root <root> --suite safety --max-risk destructive
```
客户端用例必须声明 `journey`、`fixture`、`session`、`cleanup` 和 `platform`。旅程包含
`goal`、`entry_state`、`actions`、`assertions`、`final_state`、`failure_policy`;fixture
至少声明测试账号;session 指向 catalog 中可复用的 browser/mobile session;cleanup 要有
步骤或命令(只读也要显式说明无需清理);platform 只能是 Android/iOS。具体字段见
`references/schema.md`。平台适配只能改操作层,不能把真实客户端操作换成 API 调用。
客户端运行器统一遵守 `preflight → 启动 → 登录 → 旅程 → 断言 → 清理 → 报告`。正确测试
账号登录失败是产品 `fail`;设备、产物、服务或 session 不可达才是 `environment-fail`。
fixture 或 cleanup 缺失会在 `catalog.py select` 的 preflight 阶段阻断,不产生虚假逐条结果。
设备、产物、服务或 session 能力缺失时 preflight 必须明确 blocked/environment-fail,并为所有选中的 testcase 生成
`not-run` 结果行;本项目默认具备相册、照片权限、测试图片或中文拼音 composition/candidate
能力,不在 preflight 中校验或静默变成 `skipped`,由 journey 真实验证并记录产品失败。独立用例继续执行,依赖失败的用例写 `not-run` 和 `blocked_by`;
修复后按 targeted rerun,再执行 full integration → journey。成绩仍写现有
`runs/<run-id>.yaml` 并用 `generate_run_csv.py` 生成同名 CSV;YAML 可带 `defects`/
`test_issues` 与 `feedback`,记录 testcase、product/test issue 类型、expected/observed、
artifact hash、environment、证据路径、复现和下一步,不添加新的 result 值。
PR 触发的失败回流必须先更新 PR/关联 issue,再通知 dispatcher 重新分配开发;需求/bug
触发的失败直接返回开发流程。tester 只生成结构化回流计划,不在本地偷偷修改远端。
+25 -1
View File
@@ -35,11 +35,19 @@ catalog(整个测试集)
| `session` | 可复用会话(浏览器 / 模拟器) | pytest fixture;Robot suite variable | 不是一条用例 |
| `gate` | 项目细分闸门名 → 环境变量(`motion` / `hardware` / `weld`) | 授权开关 | 不是 `risk`,不要拿它代替风险等级 |
| `authorize` | 风险档 → 环境变量。该档执行前必须为 `true` | pytest `skipif` 条件 | 不是认证(auth),是**授权** |
| `execution_profile` | 执行环境策略:`standard` 或隔离可重置的 `isolated-full` | pytest marker/fixture policy | 不是风险等级;isolated-full 不会抹掉 `risk` |
| `assume` | 额外跳过条件(gate / authorize 之外的环境表达式) | JUnit `assumeTrue`;pytest `skipif` | 不是授权开关的重复写法 |
| `status_probe` | 分类级前置条件:请求一个 URL,命中即视为前置不满足 | pytest session fixture;Robot Suite Setup | 不是产品失败,不是整轮硬闸 |
| `how` | 怎么测:`steps` / `run` / `url` / `timeout` | test steps | 不是判定标准 |
| `expected` | 怎么算过:`{kind, value}` 列表 | pytest `assert`;Robot `Should …` | 不是步骤 |
| `run_together` | 必须成组执行的用例(有序、可选 continue_on_fail) | 场景组(scenario) | 不是 suite,不是 tag |
| `client_suite` | 客户端用户旅程套件:`client-smoke` / `client-regression` / `safety` | mobile/browser E2E suite | 不是 `category`,也不是通用 tag;迁移期可读 `suite` 别名 |
| `test_type` | 顶层测试类型:`journey` 或 `integration` | E2E vs service/contract test | 不是 driver;旧目录可由既有字段推断 |
| `integration_suite` | 集成套件:`api-contract` / `authorization` / `build-contract` | API/contract test suite | 不是客户端 `client_suite`;迁移期可读 `suite` 别名 |
| `journey` | 用户目标、入口、操作、UI 断言、最终状态和失败继续策略 | Cucumber scenario / Robot task | 不是 API contract |
| `fixture` | 旅程开始前声明的测试账号与隔离数据 | pytest fixture | 不是运行中临时猜出的数据 |
| `cleanup` | 本轮数据的 teardown 契约;缺失会在 preflight 阻断 | pytest teardown / Robot teardown | 不是测试通过后的可选备注 |
| `platform` | 旅程适用的 `android`/`ios` 平台列表 | device matrix | 不是 tester Agent binding;binding 仍在命令参数/运行环境 |
### 结果词
@@ -48,7 +56,7 @@ catalog(整个测试集)
| `pass` | 通过 | passed |
| `fail` | 产品缺陷 | failed |
| `environment-fail` | 前置/环境导致无法执行,**永远不是产品失败** | pytest error;JUnit aborted |
| `skipped` | **主动**不跑:`retired` / `pending` / 超风险上限 / 未授权 / `assume` 不成立 | pytest skipped;JUnit disabled |
| `skipped` | **主动**不跑:standard 下的 `retired` / `pending` / 超风险上限 / 未授权 / `assume` 不成立 | pytest skipped;JUnit disabled |
| `not-run` | **被动**没轮到:失败即停后剩下的;分类被 `status_probe` 挡住整组没执行;`automation` 需人工而无人值守 | pytest not run;deselected |
## 2. 命名迁移对照(v1 → v2)
@@ -75,6 +83,8 @@ catalog(整个测试集)
- 三级**单调**:`readonly ⊂ mutating ⊂ destructive`,所以只需要一个上限 `default_max_risk` 和一个 `--max-risk`,不用组合矩阵。
- 与 `gate` 正交:`risk` 说「影响多大」,`gate` 说「碰哪个子系统」。点动 vs 焊接这种细分授权用 `gate`,不要再造 risk 名。
- `tag=smoke` 只能配 `risk=readonly`:冒烟测试不许留下变化。
- 隔离可重置环境可设置 `execution_profile: isolated-full` 跑所有 active 用例;安全前置由
test-preflight 的设备、服务与 cleanup/rollback 契约保证,原始 risk 仍必须报告;客户端能力由 journey 用例验证。
### 3.2 `automation`:为什么是 `automated / assisted / manual`
@@ -86,6 +96,7 @@ catalog(整个测试集)
- `assisted`:Agent 驱动,但至少一步需要人在场;该步在 `how.steps` 里以 `[人工]` 开头(可被 `validate` 机器判定)。
- `manual`:只能人工执行;禁止 `driver`、禁止 `how.run`,必须有 `how.steps`。
- 默认只跑 `automated`。无人值守时 `assisted` / `manual` 记 `not-run`;有人在场时 `assisted` 暂停等人确认,`manual` 由人工回报结果。
- isolated-full 只取消风险/授权过滤,不伪造 assisted/manual 的人工步骤。
### 3.3 结果为什么拆出 `not-run`
@@ -99,6 +110,19 @@ catalog(整个测试集)
- 因此它是**分类级前置条件**:`catalog.status_probe` 是全局默认,`categories[].status_probe` 可覆盖(缺省继承 / `false` 关闭 / mapping 整体替换)。对应 pytest 的 session fixture、Robot 的 Suite Setup。
- 命中或不可达只影响用它的分类;只有所有选中分类都被挡住,整轮结论才是 `environment-fail`。`select` 会按分类返回解析后的生效值,Agent 不要自己读 catalog 猜。
### 3.5 客户端套件和生命周期
客户端套件固定为:
- `client-smoke`:启动、正确账号登录和少量只读主路径,通常配 `tag=smoke`、`risk=readonly`。
- `client-regression`:完整的普通用户旅程,可包含隔离环境中的 `mutating` 操作;不等于所有 full 用例。
- `safety`:删除、权限边界、改密、停用或故障注入等危险旅程;必须显式选择和授权,不进入默认运行。
每条旅程按 `preflight → 启动 → 登录 → 旅程 → 断言 → 清理 → 报告` 执行。登录失败在
客户端和正确测试账号都已就绪时记 `fail`;设备、产物、服务或 session 不可达才记
`environment-fail`。fixture/cleanup 缺失是 contract block,不伪装成逐条结果。前置失败
后的剩余条目是 `not-run`,主动排除仍是 `skipped`。
## 4. 不采用的词
| 词 | 不用它的原因 |
+26 -5
View File
@@ -10,12 +10,31 @@
```yaml
bindings:
tester.test:
tester.android:
adapter: tester
location: local-app
profile: test-agent
tester.ios:
adapter: tester
location: remote-ios
profile: test-agent
```
`tester.android` / `tester.ios` 是推荐的平台绑定名。`builder.android`、`builder.ios`
只负责构建,不能被 tester 自动继承;这条边界用于防止 iOS 构建成功却仍在 Linux
上执行测试。`tester.test` 只能用于明确的单平台或非移动测试;同一轮包含 Android
和 iOS 时必须分别解析两个 tester 绑定。
移动 bundle 入口必须把平台绑定显式传入:
```bash
--binding android=tester.android --binding ios=tester.ios
```
入口会校验 preflight matrix 的每个平台 binding、node 和 runner。远程 iOS 必须是
`contract` runner;多平台运行不会读取旧的单一 `local.yaml.agent` 来决定执行节点,
也不会在协调节点直接执行 Android/iOS 平台命令。
`locations` 和 `profiles` 的定义见共享 `agent-runtime`。tester adapter 只把它 materialize
到本轮兼容配置;每轮 terminal 仍然新建并在结束时关闭,不能因为 binding 是 persistent
就复用测试 terminal。
@@ -54,7 +73,8 @@ terminal。
- Orca terminal id,例如 `term_...`;
- 不提供定位信息,使用当前项目。
有定位信息时优先使用解析脚本,避免手工拼接 Orca JSON:
有定位信息时优先使用解析脚本,避免手工拼接 Orca JSON。多平台运行要对每个
`tester.<platform>` 绑定分别解析,不能只解析一次 `tester.test` 后复用到所有平台:
```bash
python3 <skill-dir>/scripts/resolve_agent.py \
@@ -101,9 +121,10 @@ python3 <skill-dir>/scripts/resolve_agent.py \
之后重新查询并取得 `worktree` selector。项目准备失败要记为
`environment-fail`,不要继续创建 terminal。
这里的“没有项目就创建”指创建/登记 Orca 的项目记录,不是删除或重建用户目录,
也不是创建新的 Git worktree。目标目录必须已经存在且包含待测项目;如果目录本身
不存在,停止并说明需要先在目标机器准备项目。
这里的“没有项目就创建”指创建/登记 Orca 的项目记录,不是删除或重建用户目录。
目标目录必须已经存在且包含待测项目;如果目录本身不存在,停止并说明需要先在
目标机器准备项目。移动端准备阶段会另外为本轮创建隔离 Git worktree,那一步
不替代这里的 Orca 项目登记。
## 配置更新结果
+5 -1
View File
@@ -36,7 +36,11 @@
| `destructive` | 难恢复或伤人/伤钱/伤环境 | 删库、真实扣费 | 清数据、内购 | 急停、断电、真焊 |
默认只跑 `readonly`。项目细分闸(点动 vs 焊接)用可选 `gate`,不要再发明 risk 名。
隔离且可重置的项目可在 catalog 设置 `execution_profile: isolated-full`,此时所有 active
用例按原始 risk 执行;`risk` 仍用于报告和 cleanup 设计,不能改写为 readonly。
`tag=smoke` 且 `risk!=readonly` 为非法。
不想跑某一档时,`authorize.<risk>` 指向的环境变量保持 false 即可——该档用例记 `skipped`,不是 `not-run`。
standard 下不想跑某一档时,`authorize.<risk>` 指向的环境变量保持 false 即可——该档用例记
`skipped`,不是 `not-run`。isolated-full 不使用这些授权开关,改由 preflight 能力探针和
cleanup/rollback 契约阻断不安全环境。
+142 -26
View File
@@ -7,18 +7,76 @@ Agent、执行目标和临时 terminal 的完整协议见 [execution.md](executi
已有 terminal。
测试结束、失败、超时或中断时,都要关闭本轮创建的 terminal。
## 0. 准备执行目标
## 0. 建立测试矩阵并完成全量 preflight
先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标项目必须包含
可读取的 `.pouch/tester/`;如果目标目录没有测试目录,记为 `environment-fail`,不要
偷偷回退到当前项目,也不要把测试文件复制过去。
`test-preflight` 是 integration 和 journey 的共同硬闸。tester 必须先调用其 `verify`
或 `prepare`,保存独立的 preflight YAML/CSV;状态为 `blocked` 或 `environment-fail`
时不创建 browser/mobile session,所有尚未执行的 testcase 记 `not-run`。preflight 证据
不算 testcase,也不进入 testcase CSV/YAML 的总数。
测试不是“找到一个能跑的设备就开始”。每轮先建立一份测试矩阵;移动端每个目标产物
必须单独占一行:
```text
平台 | 产物路径/下载地址 | commit | 包名或 Bundle ID | tester 绑定 | 执行环境
Android | … | … | page.writes | tester.android | local-app
iOS | … | … | page.writes | tester.ios | remote-ios
```
矩阵里的产物位置必须由调用方给出,值为本地文件或 URL。不能根据“曾经构建成功过”、
`builder.<platform>` 绑定、CI release 名或本机旧文件猜测本轮产物。缺少路径或下载
地址时,先报告“待确认”并停止。
平台绑定是测试执行目标,不是构建目标:`builder.ios` 只能证明构建配置,不能替代
`tester.ios`;`builder.android` 同理。多平台运行不得使用无法表达平台的通用
`tester.test` 绑定。每一行都必须解析出独立的 tester Agent、Orca 环境、项目和路径。
完成矩阵后,按以下顺序检查**所有行**,但不执行测试用例:
1. 产物:调用方给出的文件或下载地址存在;Android APK 的 package、iOS IPA 的
Bundle ID 与 catalog 契约(若有)一致。
2. 执行目标:按 [execution.md](execution.md) 精确解析每个平台的 `tester` 绑定;
不允许把本机 worktree 当成远程 Mac 的 worktree,也不允许用旧 commit 的远程目录
代替本轮产物对应的项目目录。
3. 平台环境:Android 执行 adb、设备、APK、HOST 检查;iOS 在 macOS 执行
`uname`、`xcrun`/`simctl`、模拟器或真机、App 安装、HOST 检查。
4. 依赖环境:浏览器、权限、网络、测试账号或 catalog 明确要求的其它 session 前置。
所有检查完成后才能进入下方的 `select` 和 session 初始化。任一行不可用时,必须返回
完整清单并停止:
```text
## tester preflight:阻塞
目标产物: Android, iOS
可用: <平台 + 产物 + 环境>
不可用:
- <平台>: <具体检查项和观测结果>
修复: <用户可执行动作>
验证: <可复制命令>
本轮未创建测试 session,未执行测试用例。
完成修复后重跑原 tester 命令。
```
不能因为 Android 可用就先测 Android,也不能用 API、另一平台或旧产物替代不可用行。
先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标测试项目必须包含
可读取的 `.pouch/tester/catalog.yaml`。父仓库可能包含多个测试子项目;运行前用
`catalog.py roots --root <repo-root>` 发现全部目标根,不能把父仓库无 catalog 当成失败,
也不能偷偷回退到某一个子项目或复制测试文件。所有目标根都要被 fan-out 调度,每个根独立
做产物/环境 preflight,并单独生成运行快照。
`catalog.py select` 输出的 `agent` 是本轮使用的配置快照。记录中要保留 Agent、目标
环境、主机、项目、路径、worktree id、terminal handle,以及 terminal 是否成功关闭。
## 1. 选范围
未指定范围 → `--tag smoke`,风险上限 = `default_max_risk`,`automation` 默认只跑 `automated`。
未指定范围 → `--tag smoke`,执行 profile = catalog 的 `execution_profile`(默认 `standard`),
`standard` 的风险上限 = `default_max_risk`,`automation` 默认只跑 `automated`。
`execution_profile=isolated-full` 只用于隔离可重置环境:不按风险上限或 `AUTHORIZE_*`/gate
授权变量过滤 active 用例,保留每条用例原始 `risk`;安全门禁由 test-preflight 能力探针和
cleanup/rollback 契约承担。
用户说分类、id、tag、风险上限时,原样传给 select:
@@ -26,24 +84,65 @@ Agent、执行目标和临时 terminal 的完整协议见 [execution.md](executi
python3 <skill-dir>/scripts/catalog.py select --root <root> \
[--category CAT] [--id ID]... [--tag smoke|full|slow] \
[--max-risk <risk>] [--driver script|browser|mobile] \
[--automation automated|assisted|manual|any]
[--execution-profile standard|isolated-full] \
[--automation automated|assisted|manual|any] \
[--test-type integration|journey] [--suite <client-or-integration-suite>] \
[--platform android|ios]... \
[--artifact android=<path-or-url>] [--artifact ios=<path-or-url>] \
[--binding android=tester.android] [--binding ios=tester.ios]
```
`--automation` 默认 `automated`(需人工的条目会被跳过,并在 JSON `skipped` 里说明);用户明确要求现场配合时用 `assisted`,`any` 表示不按人工参与度筛。
客户端旅程还可按标准套件选择:
`select` 的 JSON 字段:`max_risk` / `automation` / `count` / `vars`(已展开的变量,用来替换 `how` 里的 `${…}`)/ `skipped` / `run_together` / `status_probe`(按分类,`url` 已展开)/ `cases`。
```bash
python3 <skill-dir>/scripts/catalog.py select --root <root> --suite client-smoke
```
`select` 已排除 `retired` 和风险高于上限的条目(后者若用户点了具体 id,会在 JSON 的 `skipped` 里说明,不执行)。
`client-smoke`、`client-regression`、`safety` 是 `client_suite` 的固定值,不替换既有
`tag=smoke|full|slow`。`safety` 不参与未指定范围的默认选择。
集成测试显式选择:
```bash
python3 <skill-dir>/scripts/catalog.py select --root <root> --test-type integration
python3 <skill-dir>/scripts/catalog.py select --root <root> --suite api-contract
```
未指定 `--test-type`/`--suite` 时,默认选择不会包含 `test_type=integration`。
选中任一 `driver=mobile` 用例时,`--platform`、每个平台的 `--artifact` 和对应
`tester` binding 是硬性参数;`catalog.py select` 会检查产物文件/下载地址和
`.pouch/agent.yaml` 中的 binding。缺任何一项直接返回非零并打印 `preflight: blocked`,
不能拿到 cases 后绕过闸门开始测试。默认 binding 是 `tester.<platform>`,所以 iOS
会强制检查 `tester.ios`,不会回退到 `builder.ios` 或通用 `tester.test`。
`--automation` 默认 `automated`(需人工的条目会被记为 `not-run`,不会伪造人工步骤);用户明确要求现场配合时用 `assisted`,`any` 表示不按人工参与度筛。
单根 `select` 的 JSON 字段:`max_risk` / `automation` / `count` / `vars`(已展开的变量,用来替换 `how` 里的 `${…}`)/ `artifacts` / `preflight` / `skipped` / `run_together` / `status_probe`(按分类,`url` 已展开)/ `cases`。从父仓库 fan-out 时返回 `mode: fan-out` 和每个根的独立结果,不能把多个根的 cases 拼成一个列表。
`standard` 的 `select` 已排除 `retired` 和风险高于上限的条目(后者若用户点了具体 id,会在 JSON 的 `skipped` 里说明);`isolated-full` 保留所有 active 条目,不因风险或授权变量 skipped。
若 catalog 有 `run_together`,命中组内任一条就把整组按 `ids` 顺序扩进列表(JSON 带 `run_together`)。这不是擅自扩大范围。
无命中:停下说明筛选条件,不要扩大范围。
## 2. 运行前闸
## 2. 双层执行
- `HOST` 未设(环境变量 / `HX_TEST_HOST` / `local.yaml` 都没有):全部停,environment-fail。
- 命中条的 `risk` 在 catalog `authorize` 里有对应变量:变量不是 `true` 则该条 `skipped`。有 `gate` 时还要 `catalog.gates` 对应变量为 `true`;`assume` 不成立同样 `skipped`。即使用户点了 id 也不执行。
- 授权/假设不成立是**主动不跑**(`skipped`),不是 `not-run`:这是我们决定不跑,不是没轮到。
preflight 通过后,先按 catalog 顺序执行 `integration`,再按客户端套件顺序执行
`journey`。集成服务不可达是 `environment-fail`;服务已达但 API/权限/数据契约断言错误
是 `fail`。任何 integration `fail` 都阻断依赖的 journey,并把它们记为 `not-run`;此时
不得创建客户端 session。正确测试账号登录失败发生在客户端已启动后,仍是产品 `fail`。
运行器可复用 `scripts/two_tier.py` 的 `run_integration_first`,它只接收外部 driver
回调,不实现 API、浏览器或移动执行器;mock fixture 只能验证调用顺序和失败传播,不能
冒充真实部署、Mac 或设备成功。
## 3. 运行前闸
- `HOST` 未设(环境变量 / `local.yaml` 都没有):全部停,environment-fail。
- `standard`:命中条的 `risk` 在 catalog `authorize` 里有对应变量时,变量不是 `true` 则该条 `skipped`;有 `gate` 时还要对应变量为 `true`,`assume` 不成立同样 `skipped`。即使用户点了 id 也不执行。
- `isolated-full`:不读取 `AUTHORIZE_MUTATING`、`AUTHORIZE_DESTRUCTIVE`、`AUTHORIZE_ALBUM`、`AUTHORIZE_PASSWORD` 等人工开关;相册、照片权限/测试图片和中文拼音能力默认具备,由 journey 用例真实验证,失败记为产品失败。
- standard 中授权/假设不成立是**主动不跑**(`skipped`);isolated-full 不因客户端能力授权变量停止,设备、产物、服务或清理前置失败时才为全部 testcase 写 `not-run` + `blocked_by: [test-preflight]`。
- `status_probe` 是**分类级前置条件**(业界对应 pytest 的 session fixture / Robot 的 Suite Setup)。用 `select` 返回的 `status_probe` 解析生效值,不要自己读 catalog 猜。返回的 `probe.url` 已用 `vars` 展开,直接请求即可;`select` 同批返回 `vars`:
| `categories[].status_probe` | 生效探测 |
@@ -53,10 +152,10 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
| mapping | 整体覆盖顶层,不合并(`source: category`)|
- 同一生效探测一次运行只请求一次,结果复用。
- 探测不可达:用它的分类标 environment-fail,其余分类照跑。
- 命中 `stop_if`:把用它的分类标 environment-fail 并记 `observed` 值,其余分类照跑;所有选中分类都被挡住时,整轮结论才是 environment-fail。
- 探测不可达或命中 `stop_if`:在 preflight 阶段记录对应分类/平台的不可用项并停止整轮;
先返回用户处理,不创建后续测试 session。只有所有本轮目标环境通过,才进入用例执行。
## 2.1 移动端平台环境闸门与用户引导
## 3.1 移动端平台环境闸门与用户引导
`driver=mobile` 不是一个可以互换的平台。运行前必须根据 catalog session、用例变量或用户指定,明确本轮是 Android 还是 iOS;无法判断时先停下询问平台,不得猜测。两类平台分别执行以下闸门:
@@ -66,7 +165,7 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
1. `adb` 可执行,且版本与当前 SDK 配置一致。
2. `adb devices -l` 至少有一个状态为 `device` 的目标;`unauthorized`、`offline` 或空列表都不能继续。多个目标时必须显式设置 `ANDROID_SERIAL`。
3. 被测 APK 已安装,包名与 catalog 的变量一致;可用 `adb -s "$ANDROID_SERIAL" shell pm path "$PACKAGE"` 验证。
3. 调用方已给出本轮 APK 的本地路径或 URL。由 tester skill 取用或下载该文件,读取 APK 身份,自动执行 `adb install -r`,再用 `adb -s "$ANDROID_SERIAL" shell pm path <package>` 验证。
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从设备访问。
### iOS
@@ -75,10 +174,11 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
1. 运行节点是 macOS,并且 `xcrun`、Xcode Command Line Tools 和 `simctl` 可用。Linux/Windows 节点不能伪装成 iOS 环境;需要 macOS 或可访问的远程 Mac。
2. 模拟器用 `xcrun simctl list devices available` 确认目标存在且已启动,再用 `xcrun simctl bootstatus <UDID> -b` 确认完成启动;真机用 `xcrun xctrace list devices` 确认已连接、已信任且允许开发。
3. 被测 IPA/App 已安装,Bundle ID 与 catalog 的变量一致;模拟器可用 `xcrun simctl get_app_container <UDID> <BUNDLE_ID>` 验证。
3. 调用方已给出本轮 IPA 的本地路径或 URL。由 tester skill 在 tester.ios 的 Mac 取用或下载该文件,解包读取 `Payload/*.app/Info.plist`,校验 Bundle ID,自动执行 `xcrun simctl install`,再用 `xcrun simctl get_app_container <UDID> <BUNDLE_ID>` 验证。
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。
任一项不满足,都要在执行记录中把受影响的移动用例标为 `environment-fail`,不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化后续移动 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。
Android+iOS 默认是全量 preflight 闸门:任一项不满足,另一平台也不得安装或执行,并在记录中标为 `not-run`。只有用户显式加 `--partial` 才允许已通过的平台继续。不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化被闸门挡住的平台 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。
任一项不满足,都要在 preflight 汇总中列出平台、具体检查项、实际探测命令和简短输出,然后停止整轮;只有 preflight 全部通过后,才把对应移动用例的结果写入运行快照。
### 环境缺失时的用户引导
@@ -104,7 +204,7 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
可按实际缺失替换为以下平台化提示:
- Android 无设备:提示用户连接并解锁设备、开启 USB 调试、接受 RSA 授权;多个设备时设置 `ANDROID_SERIAL`;再运行 `adb devices -l`,直到目标状态为 `device`。
- Android 未安装 APK:提示用户构建/安装与当前测试环境匹配的 APK,并用 `adb -s "$ANDROID_SERIAL" shell pm path "$PACKAGE"` 验证包名。
- Android 缺少产物位置:停下向调用方要本轮 APK 的本地路径或 URL,不要手工 scp 或沿用旧包。
- iOS 非 macOS 或无 Xcode:明确说明 iOS 测试需要 macOS + Xcode/Command Line Tools,提示用户切换到本机 Mac 或配置可访问的远程 Mac;不要建议在 Linux 上安装一个同名命令来绕过检查。
- iOS 模拟器未启动:提示用户在 Xcode 或 `simctl` 中创建/启动可用设备,使用 `xcrun simctl bootstatus <UDID> -b` 验证。
- iOS 真机未就绪:提示用户连接数据线、解锁并信任此电脑、开启开发者模式并确认设备出现在 `xcrun xctrace list devices`。
@@ -116,7 +216,7 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
环境引导只负责让用户补齐前置条件;除 catalog 已明确授权的 session 初始化外,不自动安装 SDK、配对真机、修改系统安全设置或上传产物。
## 3. Session
## 4. Session
看命中条用到的 `session` 键:
@@ -125,9 +225,9 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
- `reuse: run`:整轮结束再关。`persist`:不关。
- 中途 session 挂了:后续同 driver 标 environment-fail,不要标产品失败。
## 4. 执行
## 5. 执行
按 select 返回的顺序,一条一条来。select 按 `catalog.yaml` 的 `categories` 顺序,同一分类内按该 yaml 列表顺序(不是文件名字母序)。默认失败即停(产品 `fail` 或探测到的不安全状态)。用户说继续 / keep-going 才往后跑。
按 select 返回的顺序,一条一条来。select 按 `catalog.yaml` 的 `categories` 顺序,同一分类内按该 yaml 列表顺序(不是文件名字母序)。同一 phase 的独立用例继续执行;只有显式 `depends_on` 的失败依赖才写 `not-run` + `blocked_by`。用户说 targeted rerun 时只重跑受影响用例,修复后再 full integration → journey。
例外:当前条和下一条同属 `run_together` 且该组 `continue_on_fail: true` 时,前一条产品 `fail` 仍跑后一条。`environment-fail` 仍整轮停。
@@ -157,9 +257,9 @@ python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id>
挂到已有 session,执行 `how.steps`,对照 `expected`。必须留下可见文本、URL 或交互证据。
## 5. 记录与报告
## 6. 记录与报告
每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped` / `not-run`。`skipped` 是主动不跑(retired / pending / 超风险上限 / 未授权 / `assume` 不成立),`not-run` 是没轮到(失败即停后剩下的 / 被前置整组挡住的 / 需人工而无人值守的)。
每条:`id, result, evidence, notes`,依赖阻断时另有 `blocked_by`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped` / `not-run`。`skipped` 是 standard 中主动不跑(retired / pending / 超风险上限 / 未授权 / `assume` 不成立);`not-run` 是没轮到或 preflight/依赖阻断。失败用例可在 YAML 顶层 `defects`(兼容 `test_issues`)记录 testcase、`issue_type=product|test`、expected/observed、artifact hash、environment、evidence path、reproduction、next step。
写入 `.pouch/tester/runs/<UTC时间>-<范围>-<随机后缀>.yaml`,可用
`generate_run_csv.new_run_id(<范围>)` 生成同名 `runId`。记录包含唯一 `runId`、筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。记录文件只创建一次,不覆盖已有文件。立刻用同一快照生成 CSV,不要手改:
@@ -168,7 +268,23 @@ python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id>
python3 <skill-dir>/scripts/generate_run_csv.py --root <root> --run <yaml>
```
这会写出与快照同名的 `.pouch/tester/runs/<run-id>.csv`(`executed=yes|no`),不会修改 `cases.csv`。对话里两个路径都报。
这会写出与快照同名的 `.pouch/tester/runs/<run-id>.csv`(`executed=yes|no`),不会修改
`cases.csv`。对话里两个路径都报。
需要复核已有成绩单时,显式同时传入同一次运行的 CSV 和 YAML:
```bash
python3 <skill-dir>/scripts/summarize_run.py \
--csv .pouch/tester/runs/<run-id>.csv \
--yaml .pouch/tester/runs/<run-id>.yaml
```
汇总状态只读取 CSV `result` 列,必须输出五类 `pass / fail / environment-fail /
skipped / not-run`,且计数和等于 CSV 数据行数。汇总前会检查 CSV 数据行数、CSV 唯一
testcase id 数、YAML `results` 数量和 CSV/YAML testcase id 集合;重复或缺失 id、缺失或
未知 CSV 状态、任一数量或集合不一致都会非零退出并给出诊断。YAML 的 `counts` 或其它
历史汇总字段不会参与状态计数。退出码 0 表示汇总校验成功,非零表示报告不一致或输入
无效,不等同于产品测试失败。
对话报告:
+92 -1
View File
@@ -14,6 +14,10 @@ runs/<run-id>.yaml 每次运行快照(执行源);run-id 必须唯一
runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对应 yaml 生成
```
两类用例共用同一套 cases/runs 和五态报告,不另造成绩单。新目录显式声明顶层
`test_type: journey | integration`;旧目录没有该字段时按已有 `client_suite`/`journey`
或 `integration_suite` 推断。`category` 仍是文件/执行顺序容器,不被套件字段替代。
## catalog.yaml
| 字段 | 含义 |
@@ -21,17 +25,25 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
| `categories` | `{id, title, status_probe?}` 列表,id 与 `cases/<id小写>.yaml` 对应;列表顺序即 `select` / 执行顺序。`status_probe` 见下 |
| `risks` | 从低到高,只能是 `readonly` → `mutating` → `destructive` 的一段,且以 `readonly` 开头。含义见 [levels.md](levels.md) |
| `default_max_risk` | 未指定时的运行上限,必须是 `risks` 里的值 |
| `execution_profile` | `standard`(默认)或 `isolated-full`。后者只用于隔离可重置环境,保留 risk 语义但不按 risk/authorize/gate 过滤 active 用例;设备、服务和 cleanup/rollback 契约仍由 preflight 检查,客户端能力由 journey 真实验证 |
| `vars` | 可替换变量,如 `API_BASE: http://${HOST}:8095` |
| `artifacts` | 可选的移动产物身份契约。按 `android` / `ios` 声明 `required` 和 `package` / `bundle_id`;每轮文件路径或下载地址由调用方提供,不提交临时产物路径。 |
| `authorize` | 风险名 → 环境变量名。该档执行前变量必须为 `true`(`readonly` 不需要) |
| `gates` | 可选。项目细分闸名 → 环境变量。用在用例的 `gate` 上,不要拿它代替 `risk` |
| `sessions` | 可复用会话。`kind`: `browser` / `mobile`;`reuse`: `run`(本轮结束关掉)或 `persist`(probe 活着就附着) |
| `id_pattern` | 用例 id 正则 |
| `run_together` | 可选。成组执行:`[{ids: [TC-…, TC-…], continue_on_fail: true}]`。点到组内任一条,`select` 会按 `ids` 顺序把整组扩进命中列表。`continue_on_fail: true` 时组内前一条产品 fail 仍跑后一条(返回/收尾)。一个 id 只能属于一组。 |
| `status_probe` | 可选。顶层默认值,一个**分类级前置条件**:`{url, stop_if: {字段: [命中值]}}`。运行前请求 `url`,响应命中任一 `stop_if` 条件即视为前置不满足。分类项可用同名键覆盖:缺省继承顶层、`false` 关闭、mapping 整体替换。命中或不可达只影响用它的分类,不是产品失败。业界对应 pytest 的 session fixture / Robot 的 Suite Setup。`select` 会按分类返回解析后的生效值,`url` 已用 `vars` 展开 |
| `client_suites` | 可选的标准套件声明;出现时必须完整包含 `client-smoke`、`client-regression`、`safety`。缺省运行仍按 `tag` 兼容旧目录 |
| `integration_suites` | 可选的集成套件声明;标准值为 `api-contract`、`authorization`、`build-contract` |
`isolated-full` 不得把风险字段改成 `readonly`,也不得把 `AUTHORIZE_ALBUM`、
`AUTHORIZE_PASSWORD`、`IME_PINYIN` 等变量当成门禁。移动用例必须通过 test-preflight
的设备、产物、服务和网络检查;相册、权限和输入法能力默认具备,交由 journey 用例验证。
`local.yaml` 与环境变量覆盖 `HOST` 和授权开关。模板中的 `HOST` 默认是
`127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` >
`catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。
`catalog.yaml` 的 vars。
## agent
@@ -69,6 +81,7 @@ terminal;运行结束用 `orca terminal close --terminal <handle>` 关闭本
| 字段 | 必填 | 含义 |
|------|------|------|
| `id` | 是 | 匹配 `id_pattern` |
| `test_type` | 新用例是 | `journey` 或 `integration`;缺省兼容旧目录并按字段推断 |
| `category` | 是 | 必须等于文件声明的 category |
| `title` | 是 | 短名 |
| `what` | 是 | 一句可观察的「测什么」 |
@@ -83,10 +96,67 @@ terminal;运行结束用 `orca terminal close --terminal <handle>` 关闭本
| `expected` | 是 | 怎么过,至少一条 `{kind, value}` |
| `maps_to` | 否 | 对人文档的功能项 id |
| `assume` | 否 | 额外跳过条件(环境变量表达式)。不要重复 `risk` / `gate` 已覆盖的授权变量,否则 `validate` 报错 |
| `depends_on` | 否 | 依赖的 testcase id 列表;依赖 fail/environment-fail/not-run 时本条写 `not-run` 和 `blocked_by`,独立用例继续 |
| `setup` | 否 | 人话前置 |
| `teardown` | 否 | 跑完回退 |
| `notes` | 否 | 备注 |
### 集成测试用例契约
`test_type: integration` 的用例不创建 browser/mobile session,也不能声明 `platform` 或
客户端 `journey`。必须有 `integration_suite`(迁移期可用 `suite`)且值为
`api-contract`、`authorization` 或 `build-contract`;`fixture` 必须是非空 mapping;
`request`、`input`、`call`、`integration` 之一或旧式 `how` 用于描述调用;`expected` 是
协议/数据断言。`mutating`/`destructive` 集成用例必须提供 `cleanup.steps` 或
`cleanup.run`(只读探针可用 `required: false` + `reason`)。
选择 `--suite api-contract` 或 `--test-type integration` 才会进入集成阶段;无显式类型
时的默认选择不会包含 integration。
### 客户端旅程用例契约
声明 `client_suite` 后,以下字段都必须存在;`catalog.py validate` 和 `select` 使用同一
套检查。契约缺失是 `preflight: blocked`,不生成逐条产品失败。
| 字段 | 必填 | 约束 |
|------|------|------|
| `client_suite` | 是 | `client-smoke`、`client-regression` 或 `safety`;安全套件必须显式选择,不属于默认 smoke/full |
| `journey.goal` | 是 | 用户目标,不写接口目标 |
| `journey.entry_state` | 是 | 启动/登录前的可观察入口状态 |
| `journey.actions` | 是 | 非空用户操作列表;真实 App/browser 驱动从这里开始 |
| `journey.assertions` | 是 | 非空可观察 UI 断言列表;最终业务状态可另由 `expected` 的 api-field/api-status 佐证 |
| `journey.final_state` | 是 | 旅程完成后的业务状态 |
| `journey.failure_policy` | 是 | `stop` 或 `continue`;默认不隐式吞掉前置失败 |
| `fixture` | 是 | mapping,至少有 `account`;账号和数据由 app catalog 提供,skill 不写具体账号 |
| `session` | 是 | 复用的 browser/mobile session 名,必须在 catalog.sessions 声明 |
| `cleanup` | 是 | mapping,必须有非空 `steps`/`run`;只读旅程也要用 `required: false` + `reason` 显式说明无需清理 |
| `platform` | 是 | `android`/`ios` 的字符串或列表,不能重复;平台差异只能进入执行适配层 |
一个最小形状如下(值是占位符,不是 app 规则):
```yaml
client_suite: client-smoke
platform: [android, ios]
journey:
goal: "用户完成一个可观察的客户端目标"
entry_state: logged_out
actions: ["启动客户端", "输入测试账号并登录", "完成目标操作"]
assertions: ["看到目标页面", "看到成功提示"]
final_state: "目标状态已保存"
failure_policy: stop
fixture:
account: test-role
data: []
session: mobile
cleanup:
steps: ["删除本轮 run-id 创建的数据"]
```
账号登录属于 session 初始化:正确测试账号登录失败是产品 `fail`;设备、产物或服务
不可达才是 `environment-fail`。fixture/cleanup 缺失必须在启动、登录和任何旅程操作前
阻断。旅程失败后,尚未轮到的用例记 `not-run`;风险、生命周期或明确范围排除记
`skipped`。清理失败单独写入运行快照的 cleanup 证据,不改造为一个新的 result 值。
## how
按 `driver` 分派;`automation=manual` 时没有 `driver`,只有 `how.steps`(人工步骤)。
@@ -106,3 +176,24 @@ terminal;运行结束用 `orca terminal close --terminal <handle>` 关闭本
## 判定
`pass` / `fail` / `environment-fail` / `skipped` / `not-run`
## 运行失败回流
run YAML 的 `results` 必须保留每条 testcase 行。失败可在顶层 `defects`(兼容别名
`test_issues`)追加:
```yaml
defects:
- testcase: TC-CLIENT-001
issue_type: product # product | test
expected: "候选词可选择"
observed: "composition 成功但候选列表为空"
artifact_hash: sha256:...
environment: "Android emulator API 35 / tester.android"
evidence_path: .pouch/tester/evidence/TC-CLIENT-001.png
reproduction: "打开相册后输入 nihao"
next_step: "开发修复候选词渲染后 targeted rerun"
```
PR 触发的 run 可带 `feedback` 计划:先更新 PR/关联 issue,再通知 dispatcher 重新分配开发;
需求/bug 触发则 route 直接为 development。tester 只记录计划,不隐式调用远端写操作。
+25
View File
@@ -0,0 +1,25 @@
# 移动产物准备
调用方必须给出每个目标平台的产物位置:本地文件或可下载 URL。tester 不从
CI release、builder 绑定或本机旧文件猜测产物。
```bash
<skill-dir>/tester run \
--artifact android=<path-or-url> \
--artifact ios=<path-or-url> \
--platform android,ios \
--binding android=tester.android \
--binding ios=tester.ios
```
URL 就下载,本地文件就直接用。每个平台都通过自己的 `tester.<platform>` binding
校验、探测和安装;`agent-runtime` 负责把 Android 请求送到 Android 节点、把 iOS
请求送到 Mac 节点。旧的单一 `local.yaml.agent` 只能作为兼容配置,不能作为双平台
运行目标。随后由现有 tester catalog 驱动真实移动 session。默认先完成 Android+iOS 双平台
preflight;任一平台无法准备时整轮停止。只有显式加 `--partial` 才允许运行可用平台。
每轮另外创建隔离 Git worktree,不切用户当前分支。`--commit` 缺省为当前 HEAD。
运行记录写入目标项目 `.pouch/tester/runs/`,包含产物位置、sha256、身份、实际
worktree commit、节点、安装证据和 terminal 生命周期;`automatic-repair-failed`
与产品测试失败分开记录。
+491 -15
View File
@@ -4,15 +4,30 @@
from __future__ import annotations
import argparse
import contextlib
import io
import json
import os
import re
import subprocess
import sys
import urllib.parse
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from journey import (
CLIENT_SUITES,
INTEGRATION_SUITES,
TEST_TYPES,
contract_errors,
preflight as journey_preflight,
suite as case_suite,
test_type,
)
try:
import yaml
except ImportError: # pragma: no cover
@@ -27,6 +42,7 @@ LIFECYCLE_SKIP_REASON = {
}
TAGS = ("smoke", "full", "slow")
CANONICAL_RISKS = ("readonly", "mutating", "destructive")
EXECUTION_PROFILES = ("standard", "isolated-full")
AUTOMATIONS = ("automated", "assisted", "manual")
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
EXPECTED_KINDS = (
@@ -55,6 +71,109 @@ def catalog_dir(root: Path) -> Path:
return root / ".pouch" / "tester"
def catalog_path(root: Path) -> Path:
return catalog_dir(root) / "catalog.yaml"
def discover_or_self(root: Path) -> list[Path]:
"""Return all independent tester projects below a parent repository."""
root = root.resolve()
return discover_catalog_roots(root)
def cmd_fanout(args: argparse.Namespace, roots: list[Path]) -> int:
"""Run a catalog command independently for every discovered tester root."""
results: list[dict[str, Any]] = []
for child_root in roots:
output = io.StringIO()
with contextlib.redirect_stdout(output):
if args.cmd == "list":
exit_code = cmd_list(child_root)
elif args.cmd == "select":
exit_code = cmd_select(args, child_root)
elif args.cmd == "show":
exit_code = cmd_show(child_root, args.case_id)
elif args.cmd == "validate":
exit_code = cmd_validate(child_root)
elif args.cmd == "export-csv":
exit_code = cmd_export_csv(child_root)
elif args.cmd == "run-script":
exit_code = cmd_run_script(child_root, args.case_id)
else:
exit_code = 2
print(f"不支持从父仓库 fan-out 命令: {args.cmd}")
raw = output.getvalue().strip()
try:
result: Any = json.loads(raw) if raw else None
except json.JSONDecodeError:
result = raw
results.append({
"root": str(child_root),
"exit": exit_code,
"result": result,
})
if args.cmd == "select":
print(json.dumps({"mode": "fan-out", "roots": results}, ensure_ascii=False, indent=2))
else:
for item in results:
print(f"\n## tester root: {item['root']} (exit={item['exit']})")
result = item["result"]
if isinstance(result, (dict, list)):
print(json.dumps(result, ensure_ascii=False, indent=2))
elif result:
print(result)
return 0 if all(item["exit"] == 0 for item in results) else 2
FANOUT_COMMANDS = {"list", "select", "show", "validate", "export-csv", "run-script"}
def discover_catalog_roots(root: Path) -> list[Path]:
"""Find tester projects owned by a repository and its Git submodules.
A monorepo or a parent repository may contain more than one independent
tester catalog. Cases must stay scoped to the project that owns them; this
helper only discovers roots and never merges their cases.
"""
root = root.resolve()
candidates: list[Path] = []
if catalog_path(root).is_file():
candidates.append(root)
# Prefer the authoritative submodule list so generated directories and
# vendored dependencies are not accidentally treated as test projects.
try:
proc = subprocess.run(
["git", "-C", str(root), "config", "--file", ".gitmodules", "--get-regexp", r"^submodule\..*\.path$"],
capture_output=True,
text=True,
check=False,
)
for line in proc.stdout.splitlines():
_, _, raw_path = line.partition(" ")
subroot = (root / raw_path.strip()).resolve()
if raw_path.strip() and catalog_path(subroot).is_file() and subroot not in candidates:
candidates.append(subroot)
except OSError:
pass
# Also support a plain nested package that is not declared as a submodule.
# Only inspect a shallow set of project directories; callers can pass that
# directory explicitly when a deeper layout is intentional.
ignored = {".git", ".pouch", "node_modules", "vendor", "third_party", "dist", "build"}
try:
children = list(root.iterdir())
except OSError:
children = []
for child in children:
if not child.is_dir() or child.name in ignored or child.name.startswith("."):
continue
if catalog_path(child).is_file() and child.resolve() not in candidates:
candidates.append(child.resolve())
return sorted(candidates)
def load_yaml(path: Path) -> Any:
text = path.read_text(encoding="utf-8")
data = yaml.safe_load(text)
@@ -200,17 +319,12 @@ def resolve_vars(root: Path, meta: dict[str, Any]) -> dict[str, str]:
for key, value in os.environ.items():
if key in merged or key in auth_keys or key.startswith("AUTHORIZE_") or key in {
"HOST",
"HX_TEST_HOST",
"WEB_BASE",
"API_BASE",
"WS_URL",
"STATUS_BASE",
}:
merged[key] = value
if not merged.get("HOST") and merged.get("HX_TEST_HOST"):
merged["HOST"] = merged["HX_TEST_HOST"]
if os.environ.get("HX_TEST_HOST") and not os.environ.get("HOST"):
merged["HOST"] = os.environ["HX_TEST_HOST"]
for _ in range(10):
changed = False
for key, value in list(merged.items()):
@@ -238,6 +352,21 @@ def risk_index(meta: dict[str, Any], name: str) -> int:
return risks.index(name)
def resolve_execution_profile(meta: dict[str, Any], requested: str | None = None) -> str:
"""Resolve the run profile without changing the meaning of ``risk``.
``isolated-full`` is an execution policy for a resettable test environment:
it disables risk/authorization filtering, but leaves the catalog risk value
on every case so reports still describe the side effect that was exercised.
"""
profile = requested or meta.get("execution_profile") or "standard"
if profile not in EXECUTION_PROFILES:
raise SystemExit(
f"未知 execution_profile: {profile!r}(可选: {', '.join(EXECUTION_PROFILES)})"
)
return str(profile)
def host_set(vars_map: dict[str, str]) -> bool:
host = (vars_map.get("HOST") or "").strip()
return bool(host) and "${HOST}" not in host
@@ -265,7 +394,7 @@ def cmd_list(root: Path) -> int:
pending = [c for c in cases if c.get("lifecycle") == "pending"]
print(f"## 测试目录 active {len(active)} / pending {len(pending)} / retired {len(retired)}")
print()
headers = ["分类", "标题", "条数", "smoke"] + risks + list(DRIVERS)
headers = ["分类", "标题", "条数", "journey", "integration", "smoke"] + risks + list(DRIVERS)
print("| " + " | ".join(headers) + " |")
print("| " + " | ".join("---" for _ in headers) + " |")
@@ -283,7 +412,14 @@ def cmd_list(root: Path) -> int:
title = (cats_meta.get(cat) or {}).get("title") or (
group[0].get("_file_title") if group else ""
)
row = [cat, str(title), str(len(group)), str(sum(1 for c in group if c.get("tag") == "smoke"))]
row = [
cat,
str(title),
str(len(group)),
str(sum(1 for c in group if test_type(c) == "journey")),
str(sum(1 for c in group if test_type(c) == "integration")),
str(sum(1 for c in group if c.get("tag") == "smoke")),
]
for risk in risks:
row.append(str(sum(1 for c in group if c.get("risk") == risk)))
for driver in DRIVERS:
@@ -450,6 +586,7 @@ def expand_run_together(
skipped: list[dict[str, str]],
max_i: int,
automation_filter: str = "automated",
bypass_risk: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
groups = run_together_groups(meta)
if not groups:
@@ -487,7 +624,7 @@ def expand_run_together(
except SystemExit:
skipped.append({"id": cid, "reason": f"未知风险 {risk}"})
continue
if r_i > max_i:
if not bypass_risk and r_i > max_i:
skipped.append({"id": cid, "reason": f"risk={risk} 超过上限"})
continue
members.append(public_case(case))
@@ -511,12 +648,150 @@ def expand_run_together(
return selected, hit_groups
def parse_platform_pairs(values: list[str], label: str) -> tuple[dict[str, str], list[str]]:
pairs: dict[str, str] = {}
errors: list[str] = []
for raw in values:
platform, sep, value = raw.partition("=")
platform = platform.strip().lower()
value = value.strip()
if not sep or platform not in ("android", "ios") or not value:
errors.append(f"{label} 格式必须是 android=<值> 或 ios=<值>: {raw}")
continue
if platform in pairs:
errors.append(f"{label} 重复平台: {platform}")
continue
pairs[platform] = value
return pairs, errors
def safe_locator(locator: str) -> str:
parsed = urllib.parse.urlsplit(locator)
if parsed.scheme in ("http", "https"):
path = parsed.path or "/"
return f"{parsed.scheme}://{parsed.netloc}{path}"
return locator
def artifact_ready(locator: str) -> tuple[bool, str]:
parsed = urllib.parse.urlsplit(locator)
if parsed.scheme in ("http", "https"):
try:
request = urllib.request.Request(locator, method="HEAD")
with urllib.request.urlopen(request, timeout=10) as response:
if 200 <= response.status < 400:
return True, f"HTTP {response.status}"
return False, f"HTTP {response.status}"
except Exception as exc:
return False, f"下载地址不可达: {type(exc).__name__}: {exc}"
path = Path(locator).expanduser()
if not path.is_file():
return False, "文件不存在"
if path.stat().st_size <= 0:
return False, "文件为空"
if path.suffix.lower() in (".apk", ".ipa") and not zipfile.is_zipfile(path):
return False, "不是有效的 APK/IPA zip 产物"
return True, f"文件 {path.stat().st_size} bytes"
def runtime_binding(root: Path, binding_id: str) -> tuple[bool, str]:
config_path = root / ".pouch" / "agent.yaml"
if not config_path.is_file():
return False, f"缺少 {config_path}"
try:
config = load_yaml(config_path) or {}
except Exception as exc:
return False, f"读取 agent.yaml 失败: {exc}"
binding = (config.get("bindings") or {}).get(binding_id)
if not isinstance(binding, dict):
return False, "binding 不存在"
if binding.get("adapter") != "tester":
return False, f"adapter={binding.get('adapter')!r},不是 tester"
location = binding.get("location")
if not location:
return False, "未配置 location"
return True, f"location={location}"
def mobile_preflight(
root: Path,
meta: dict[str, Any],
selected: list[dict[str, Any]],
platforms_raw: list[str],
artifacts_raw: list[str],
bindings_raw: list[str],
) -> dict[str, Any]:
mobile_selected = [case for case in selected if case.get("driver") == "mobile"]
if not mobile_selected:
return {"required": False, "status": "not-needed", "checks": [], "errors": []}
platforms = [item.strip().lower() for item in platforms_raw if item.strip()]
errors: list[str] = []
if not platforms:
errors.append("选中了 mobile 用例,必须显式提供 --platform android/ios")
if len(platforms) != len(set(platforms)):
errors.append("--platform 不能重复")
invalid_platforms = [item for item in platforms if item not in ("android", "ios")]
if invalid_platforms:
errors.append("非法平台: " + ", ".join(invalid_platforms))
artifacts, pair_errors = parse_platform_pairs(artifacts_raw, "--artifact")
errors.extend(pair_errors)
bindings, pair_errors = parse_platform_pairs(bindings_raw, "--binding")
errors.extend(pair_errors)
contract = meta.get("artifacts")
if not isinstance(contract, dict) or not contract:
errors.append("catalog 缺少 artifacts 产物契约")
checks: list[dict[str, Any]] = []
for platform in platforms:
if platform not in ("android", "ios"):
continue
spec = (contract or {}).get(platform) if isinstance(contract, dict) else None
if not isinstance(spec, dict):
errors.append(f"{platform}: catalog.artifacts 未声明")
if platform not in artifacts:
errors.append(f"{platform}: 缺少 --artifact {platform}=<路径或下载地址>")
else:
ok, detail = artifact_ready(artifacts[platform])
checks.append({
"platform": platform,
"artifact": safe_locator(artifacts[platform]),
"artifact_ok": ok,
"artifact_detail": detail,
})
if not ok:
errors.append(f"{platform}: 产物不可用: {detail}")
binding_id = bindings.get(platform, f"tester.{platform}")
ok, detail = runtime_binding(root, binding_id)
checks.append({
"platform": platform,
"binding": binding_id,
"binding_ok": ok,
"binding_detail": detail,
})
if not ok:
errors.append(f"{platform}: binding {binding_id} 不可用: {detail}")
return {
"required": True,
"status": "blocked" if errors else "ready",
"platforms": platforms,
"checks": checks,
"errors": errors,
}
def cmd_select(args: argparse.Namespace, root: Path) -> int:
meta = load_catalog_meta(root)
cases = load_all_cases(root)
execution_profile = resolve_execution_profile(meta, args.execution_profile)
bypass_risk = execution_profile == "isolated-full"
max_risk = args.max_risk or meta.get("default_max_risk") or "readonly"
max_i = risk_index(meta, max_risk)
automation_filter = args.automation or "automated"
automation_filter = args.automation or ("any" if bypass_risk else "automated")
wanted = set(parse_ids(args.ids or []))
selected: list[dict[str, Any]] = []
skipped: list[dict[str, str]] = []
@@ -542,6 +817,20 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
continue
if args.tag and case.get("tag") != args.tag:
continue
if args.test_type and test_type(case) != args.test_type:
continue
if args.suite and case_suite(case) != args.suite:
continue
# A mixed catalog keeps legacy cases runnable. Standard keeps the
# historical client-only implicit default; isolated-full means
# exactly what it says and includes every active test type.
if (
not bypass_risk
and not args.test_type
and not args.suite
and test_type(case) == "integration"
):
continue
if args.driver and case.get("driver") != args.driver:
continue
automation = str(case.get("automation") or "automated")
@@ -557,7 +846,7 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
except SystemExit:
skipped.append({"id": cid, "reason": f"未知风险 {risk}"})
continue
if r_i > max_i:
if not bypass_risk and r_i > max_i:
skipped.append({"id": cid, "reason": f"risk={risk} 超过上限 {max_risk}"})
continue
selected.append(public_case(case))
@@ -569,7 +858,34 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
)
return 2
selected, together = expand_run_together(meta, cases, selected, skipped, max_i, automation_filter)
selected, together = expand_run_together(
meta,
cases,
selected,
skipped,
max_i,
automation_filter,
bypass_risk=bypass_risk,
)
# The historical key is kept in the JSON, but the gate now validates both
# test types before any caller can start a phase.
journey_gate = journey_preflight(selected, sessions=meta.get("sessions") or {})
if journey_gate["status"] == "blocked":
print(json.dumps({"preflight": journey_gate, "journey_preflight": journey_gate}, ensure_ascii=False, indent=2))
return 2
preflight = mobile_preflight(
root,
meta,
selected,
args.platform or [],
args.artifacts or [],
args.bindings or [],
)
if preflight["status"] == "blocked":
print(json.dumps({"preflight": preflight}, ensure_ascii=False, indent=2))
return 2
# 按分类解析生效的 status_probe,让 Agent 不再自己按 catalog 猜作用范围。
vars_map = resolve_vars(root, meta)
@@ -584,9 +900,18 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
json.dumps(
{
"max_risk": max_risk,
"execution_profile": execution_profile,
"risk_filter": "disabled" if bypass_risk else "enforced",
"automation": automation_filter,
"count": len(selected),
"vars": vars_map,
"artifacts": meta.get("artifacts") or {},
"preflight": preflight,
"journey_preflight": journey_gate,
"test_types": {
"journey": sum(1 for case in selected if test_type(case) == "journey"),
"integration": sum(1 for case in selected if test_type(case) == "integration"),
},
"skipped": skipped,
"run_together": together,
"status_probe": status_probes,
@@ -657,6 +982,12 @@ def validate(root: Path) -> list[str]:
default_max = meta.get("default_max_risk")
if default_max not in risks:
errors.append(f"default_max_risk={default_max!r} 不在 risks 中")
profile = meta.get("execution_profile", "standard")
if profile not in EXECUTION_PROFILES:
errors.append(
f"execution_profile={profile!r} 非法,只能是 {', '.join(EXECUTION_PROFILES)}"
)
isolated_full = profile == "isolated-full"
gates = meta.get("gates") or {}
if gates and not isinstance(gates, dict):
errors.append("catalog.gates 必须是 mapping")
@@ -669,6 +1000,50 @@ def validate(root: Path) -> list[str]:
if not isinstance(sessions, dict):
errors.append("catalog.sessions 必须是 mapping")
sessions = {}
client_suites = meta.get("client_suites")
if client_suites is not None:
if not isinstance(client_suites, dict):
errors.append("catalog.client_suites 必须是 mapping")
else:
unknown = sorted(set(client_suites) - set(CLIENT_SUITES))
if unknown:
errors.append("catalog.client_suites 含非法套件: " + ", ".join(unknown))
missing_suites = sorted(set(CLIENT_SUITES) - set(client_suites))
if missing_suites:
errors.append("catalog.client_suites 缺少标准套件: " + ", ".join(missing_suites))
integration_suites = meta.get("integration_suites")
if integration_suites is not None:
if not isinstance(integration_suites, dict):
errors.append("catalog.integration_suites 必须是 mapping")
else:
unknown = sorted(set(integration_suites) - set(INTEGRATION_SUITES))
if unknown:
errors.append("catalog.integration_suites 含非法套件: " + ", ".join(unknown))
missing_suites = sorted(set(INTEGRATION_SUITES) - set(integration_suites))
if missing_suites:
errors.append("catalog.integration_suites 缺少标准套件: " + ", ".join(missing_suites))
artifacts = meta.get("artifacts")
if artifacts is not None:
if not isinstance(artifacts, dict):
errors.append("catalog.artifacts 必须是 mapping")
else:
for platform, spec in artifacts.items():
where = f"catalog.artifacts.{platform}"
if platform not in ("android", "ios"):
errors.append(f"{where}: 平台只能是 android/ios")
if not isinstance(spec, dict):
errors.append(f"{where}: 必须是 mapping")
continue
if "required" in spec and not isinstance(spec["required"], bool):
errors.append(f"{where}.required 必须是 boolean")
if platform == "android" and not spec.get("package"):
errors.append(f"{where}: Android 需要 package")
if platform == "ios" and not spec.get("bundle_id"):
errors.append(f"{where}: iOS 需要 bundle_id")
if platform == "android" and spec.get("bundle_id"):
errors.append(f"{where}: Android 不应填 bundle_id")
if platform == "ios" and spec.get("package"):
errors.append(f"{where}: iOS 不应填 package")
pattern = meta.get("id_pattern") or r"^.+$"
try:
id_re = re.compile(pattern)
@@ -723,6 +1098,7 @@ def validate(root: Path) -> list[str]:
errors.append(f"{where}: category={cat} 与文件 category={file_cat} 不一致")
if cat and cat_ids and cat not in cat_ids:
errors.append(f"{where}: category={cat} 不在 catalog.categories")
errors.extend(contract_errors(case, sessions=sessions))
if case.get("lifecycle", "active") not in LIFECYCLES:
errors.append(f"{where}: 非法 lifecycle")
if case.get("tag") not in TAGS:
@@ -738,8 +1114,17 @@ def validate(root: Path) -> list[str]:
if case.get("risk") in (None, "readonly"):
errors.append(f"{where}: risk=readonly 不要填 gate")
risk = case.get("risk")
if risk and risk != "readonly" and risk not in authorize and not gate:
if risk and risk != "readonly" and risk not in authorize and not gate and not isolated_full:
errors.append(f"{where}: {risk} 需要 authorize.{risk} 或 gate")
if isolated_full and case.get("lifecycle", "active") == "active" and risk != "readonly":
cleanup = case.get("cleanup")
cleanup_ok = isinstance(cleanup, dict) and (
bool(cleanup.get("steps"))
or bool(str(cleanup.get("run") or "").strip())
or (cleanup.get("required") is False and bool(str(cleanup.get("reason") or "").strip()))
)
if not cleanup_ok:
errors.append(f"{where}: isolated-full 的写入型用例必须声明 cleanup/rollback steps/run")
automation = str(case.get("automation") or "automated")
if automation not in AUTOMATIONS:
errors.append(f"{where}: 非法 automation={case.get('automation')}")
@@ -829,6 +1214,29 @@ def cmd_export_csv(root: Path) -> int:
return run_export_csv(root)
def cmd_roots(root: Path) -> int:
"""List independent tester catalog roots below a repository."""
roots = discover_catalog_roots(root)
entries: list[dict[str, str]] = []
for item in roots:
project = ""
try:
meta = load_catalog_meta(item)
project = str((meta.get("project") or {}).get("name") or "")
except (SystemExit, AttributeError):
pass
entries.append(
{
"root": str(item),
"catalog": str(catalog_path(item)),
"project": project,
"relative": str(item.relative_to(root.resolve())) if item != root.resolve() else ".",
}
)
print(json.dumps({"base": str(root.resolve()), "count": len(entries), "roots": entries}, ensure_ascii=False, indent=2))
return 0
def cmd_next_id(root: Path, category: str) -> int:
meta = load_catalog_meta(root)
cat_ids = [c.get("id") for c in meta.get("categories") or [] if isinstance(c, dict)]
@@ -848,7 +1256,19 @@ def cmd_next_id(root: Path, category: str) -> int:
return 0
def authorized(vars_map: dict[str, str], meta: dict[str, Any], case: dict[str, Any]) -> bool:
def authorized(
vars_map: dict[str, str],
meta: dict[str, Any],
case: dict[str, Any],
*,
execution_profile: str | None = None,
) -> bool:
profile = resolve_execution_profile(meta, execution_profile)
if profile == "isolated-full":
# AUTHORIZE_* and gate variables are intentionally not run-time
# switches for an isolated resettable environment. Capability and
# cleanup preflight are the safety boundary for this profile.
return True
risk = str(case.get("risk") or "readonly")
risks = list(meta.get("risks") or ["readonly"])
lowest = risks[0] if risks else "readonly"
@@ -868,6 +1288,7 @@ def authorized(vars_map: dict[str, str], meta: dict[str, Any], case: dict[str, A
def cmd_run_script(root: Path, case_id: str) -> int:
meta = load_catalog_meta(root)
execution_profile = resolve_execution_profile(meta)
vars_map = resolve_vars(root, meta)
case = next((c for c in load_all_cases(root) if c.get("id") == case_id), None)
if not case:
@@ -905,10 +1326,10 @@ def cmd_run_script(root: Path, case_id: str) -> int:
)
return 2
risk = str(case.get("risk") or "readonly")
if not authorized(vars_map, meta, case):
if not authorized(vars_map, meta, case, execution_profile=execution_profile):
print(
json.dumps(
{"id": public_id, "result": "skipped", "evidence": f"风险 {risk} 未授权"},
{"id": public_id, "result": "skipped", "evidence": f"风险 {risk} 未授权", "execution_profile": execution_profile},
ensure_ascii=False,
)
)
@@ -957,6 +1378,7 @@ def cmd_run_script(root: Path, case_id: str) -> int:
{
"id": public_id,
"result": "executed",
"execution_profile": execution_profile,
"exit": proc.returncode,
"evidence": evidence,
},
@@ -977,7 +1399,22 @@ def build_parser() -> argparse.ArgumentParser:
p_sel.add_argument("--category")
p_sel.add_argument("--id", action="append", dest="ids", default=[])
p_sel.add_argument("--tag", choices=TAGS)
p_sel.add_argument(
"--suite", "--client-suite", dest="suite",
choices=[*CLIENT_SUITES, *INTEGRATION_SUITES],
help="客户端或集成测试套件;不传时默认不选 integration",
)
p_sel.add_argument(
"--test-type", choices=TEST_TYPES, default=None,
help="按顶层 test_type 筛选;integration 必须显式选择",
)
p_sel.add_argument("--max-risk")
p_sel.add_argument(
"--execution-profile",
choices=EXECUTION_PROFILES,
default=None,
help="执行策略;isolated-full 在隔离可重置环境中不按 risk/authorize 筛选",
)
p_sel.add_argument("--driver", choices=DRIVERS)
p_sel.add_argument(
"--automation",
@@ -985,9 +1422,33 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
help="默认 automated;any 表示不按人工参与度筛选",
)
p_sel.add_argument(
"--platform",
action="append",
choices=["android", "ios"],
default=[],
help="mobile 运行平台,可重复;选中 mobile 用例时必填",
)
p_sel.add_argument(
"--artifact",
action="append",
dest="artifacts",
default=[],
metavar="PLATFORM=PATH_OR_URL",
help="本轮移动产物,可重复,例如 android=dist/app.apk",
)
p_sel.add_argument(
"--binding",
action="append",
dest="bindings",
default=[],
metavar="PLATFORM=TESTER_BINDING",
help="平台 tester binding,可重复;默认 tester.<platform>",
)
p_show = sub.add_parser("show", help="打印一条用例", parents=[parent])
p_show.add_argument("--id", required=True, dest="case_id")
sub.add_parser("validate", help="校验目录", parents=[parent])
sub.add_parser("roots", help="发现当前仓库及子项目的 tester 根目录", parents=[parent])
sub.add_parser("export-csv", help="写出给人看的 cases.csv", parents=[parent])
p_next = sub.add_parser("next-id", help="分配下一个 TC-<CAT>-NNN", parents=[parent])
p_next.add_argument("--category", required=True)
@@ -1000,6 +1461,19 @@ def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
root = Path(args.root).resolve()
if args.cmd != "roots":
roots = discover_or_self(root)
if len(roots) > 1:
if args.cmd in FANOUT_COMMANDS:
return cmd_fanout(args, roots)
print(
f"发现多个 tester 根目录,命令 {args.cmd} 不是可 fan-out 的执行命令;"
"请对具体项目根目录执行该维护命令。",
file=sys.stderr,
)
return 2
if len(roots) == 1:
root = roots[0]
if args.cmd == "list":
return cmd_list(root)
if args.cmd == "select":
@@ -1008,6 +1482,8 @@ def main(argv: list[str] | None = None) -> int:
return cmd_show(root, args.case_id)
if args.cmd == "validate":
return cmd_validate(root)
if args.cmd == "roots":
return cmd_roots(root)
if args.cmd == "export-csv":
return cmd_export_csv(root)
if args.cmd == "next-id":
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Build structured tester issues and the development feedback route.
This module only writes the run contract. Updating a remote PR/issue or
notifying a dispatcher remains an explicit caller-side integration step; no
network mutation is hidden in the tester.
"""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any, Iterable, Mapping
ISSUE_TYPES = ("product", "test")
TRIGGERS = ("pr", "requirement", "bug")
ISSUE_RESULTS = frozenset({"fail", "environment-fail"})
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def issue_type_for(result: str) -> str:
if result == "fail":
return "product"
if result == "environment-fail":
return "test"
raise ValueError(f"只有 fail/environment-fail 可以生成 issue: {result}")
def _value(row: Mapping[str, Any], key: str, defaults: Mapping[str, Any]) -> Any:
value = row.get(key)
return value if value not in (None, "") else defaults.get(key, "")
def build_issue(
row: Mapping[str, Any],
*,
defaults: Mapping[str, Any] | None = None,
issue_type: str | None = None,
) -> dict[str, Any]:
"""Create the stable issue shape required by the run report."""
defaults = defaults or {}
result = str(row.get("result") or "")
if result not in ISSUE_RESULTS:
raise ValueError(f"不能为 {result!r} 结果创建 defect: {row.get('id')}")
selected_type = issue_type or row.get("issue_type") or issue_type_for(result)
if selected_type not in ISSUE_TYPES:
raise ValueError(f"issue_type 必须是 product/test: {selected_type}")
testcase = str(row.get("testcase") or row.get("id") or "")
if not testcase:
raise ValueError("issue 缺少 testcase")
return {
"id": str(row.get("issue_id") or f"TEST-{testcase}-{result}"),
"testcase": testcase,
"issue_type": selected_type,
"result": result,
"phase": _value(row, "phase", defaults),
"expected": _value(row, "expected", defaults),
"observed": _value(row, "observed", defaults),
"artifact_hash": _value(row, "artifact_hash", defaults),
"environment": _value(row, "environment", defaults),
"evidence_path": _value(row, "evidence_path", defaults),
"reproduction": _value(row, "reproduction", defaults),
"next_step": _value(row, "next_step", defaults),
"created_at": str(row.get("created_at") or _now()),
}
def collect_issues(
results: Iterable[Mapping[str, Any]],
*,
defaults: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for row in results:
if not isinstance(row, Mapping) or str(row.get("result") or "") not in ISSUE_RESULTS:
continue
explicit = row.get("issue")
source = dict(row)
if isinstance(explicit, Mapping):
source.update(explicit)
issues.append(build_issue(source, defaults=defaults))
return issues
def feedback_plan(
trigger: str,
*,
target: str = "",
related_issue: str = "",
dispatcher: str = "dispatcher",
) -> dict[str, Any]:
"""Describe the required handoff without performing remote mutations."""
if trigger not in TRIGGERS:
raise ValueError(f"trigger 必须是 {', '.join(TRIGGERS)}")
if trigger == "pr":
actions = [
{"action": "update_pr", "target": target, "status": "required"},
{"action": "update_related_issue", "target": related_issue, "status": "required"},
{"action": "notify_dispatcher", "target": dispatcher, "status": "required"},
{"action": "reassign_development", "target": dispatcher, "status": "pending"},
]
route = "pr-update-issue-dispatcher-reassign"
else:
actions = [
{"action": "return_to_development", "target": target or trigger, "status": "required"},
]
route = "development"
return {
"trigger": trigger,
"route": route,
"target": target,
"related_issue": related_issue,
"actions": actions,
"remote_mutation": "caller-required",
"created_at": _now(),
}
def attach_feedback(
run: Mapping[str, Any],
*,
trigger: str | None = None,
target: str = "",
related_issue: str = "",
dispatcher: str = "dispatcher",
defaults: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Return a new run mapping with results, defects and routing preserved."""
output = deepcopy(dict(run))
results = output.get("results")
if not isinstance(results, list):
raise ValueError("run 缺少 results 列表")
issues = collect_issues(results, defaults=defaults)
output["defects"] = issues
# Compatibility name for consumers that call the same structure
# ``test-issues``. It is deliberately a copy, not a second source of truth.
output["test_issues"] = deepcopy(issues)
if trigger:
output["feedback"] = feedback_plan(
trigger,
target=target,
related_issue=related_issue,
dispatcher=dispatcher,
)
return output
@@ -13,6 +13,8 @@ import sys
from pathlib import Path
from typing import Any
from journey import suite as case_suite, test_type
try:
import yaml
except ImportError: # pragma: no cover
@@ -22,6 +24,10 @@ except ImportError: # pragma: no cover
FIELDS = (
"category",
"id",
"test_type",
"suite",
"client_suite",
"platform",
"maps_to",
"title",
"what",
@@ -156,10 +162,17 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path:
writer.writeheader()
for case in cases:
cid = str(case.get("id") or "")
client_suite_value = case.get("client_suite") or (
case.get("suite") if test_type(case) == "journey" else ""
)
writer.writerow(
{
"category": case.get("category") or "",
"id": cid,
"test_type": test_type(case),
"suite": case_suite(case),
"client_suite": client_suite_value or "",
"platform": one_line(case.get("platform")),
"maps_to": case.get("maps_to") or "",
"title": case.get("title") or "",
"what": one_line(case.get("what")),
+53
View File
@@ -18,6 +18,7 @@ except ImportError: # pragma: no cover
raise SystemExit(2)
from generate_cases_csv import load_cases, one_line, resolve_root, tester_dir
from journey import suite as case_suite, test_type
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
@@ -25,6 +26,10 @@ EXECUTED_YES = frozenset({"pass", "fail", "environment-fail"})
EXECUTED_NO = frozenset({"skipped", "not-run"})
FIELDS = (
"id",
"test_type",
"suite",
"client_suite",
"platform",
"result",
"executed",
"evidence",
@@ -35,6 +40,16 @@ FIELDS = (
"risk",
"driver",
"maps_to",
"blocked_by",
"issue_id",
"issue_type",
"expected",
"observed",
"artifact_hash",
"environment",
"evidence_path",
"reproduction",
"next_step",
)
@@ -107,6 +122,23 @@ def case_index(root: Path) -> dict[str, dict[str, Any]]:
return out
def issue_index(run: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Index canonical defects and compatibility test_issues by testcase."""
indexed: dict[str, dict[str, Any]] = {}
raw = run.get("defects")
if not isinstance(raw, list):
raw = run.get("test_issues")
if not isinstance(raw, list):
return indexed
for issue in raw:
if not isinstance(issue, dict):
continue
testcase = str(issue.get("testcase") or issue.get("id") or "")
if testcase:
indexed.setdefault(testcase, issue)
return indexed
def export_run_csv(
root: Path,
run_path: Path | None = None,
@@ -118,6 +150,7 @@ def export_run_csv(
path = path.resolve()
run = load_run(path)
cases = case_index(root)
issues = issue_index(run)
dest = out or path.with_suffix(".csv")
dest.parent.mkdir(parents=True, exist_ok=True)
with dest.open("w", encoding="utf-8-sig", newline="") as handle:
@@ -129,9 +162,19 @@ def export_run_csv(
cid = str(item["id"])
result = str(item.get("result") or "")
case = cases.get(cid) or {}
issue = dict(issues.get(cid, {}))
if isinstance(item.get("issue"), dict):
issue.update(item["issue"])
client_suite_value = case.get("client_suite") or (
case.get("suite") if test_type(case) == "journey" else ""
)
writer.writerow(
{
"id": cid,
"test_type": test_type(case),
"suite": case_suite(case),
"client_suite": client_suite_value or "",
"platform": one_line(case.get("platform")),
"result": result,
"executed": executed_cell(result),
"evidence": one_line(item.get("evidence")),
@@ -142,6 +185,16 @@ def export_run_csv(
"risk": case.get("risk") or "",
"driver": case.get("driver") or "",
"maps_to": case.get("maps_to") or "",
"blocked_by": one_line(item.get("blocked_by")),
"issue_id": issue.get("id") or item.get("issue_id") or "",
"issue_type": issue.get("issue_type") or item.get("issue_type") or "",
"expected": one_line(issue.get("expected") or item.get("expected")),
"observed": one_line(issue.get("observed") or item.get("observed")),
"artifact_hash": issue.get("artifact_hash") or item.get("artifact_hash") or "",
"environment": one_line(issue.get("environment") or item.get("environment")),
"evidence_path": one_line(issue.get("evidence_path") or item.get("evidence_path")),
"reproduction": one_line(issue.get("reproduction") or item.get("reproduction")),
"next_step": one_line(issue.get("next_step") or item.get("next_step")),
}
)
return dest
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""Shared client-journey contract and result lifecycle helpers.
The mobile/browser driver still owns the real UI interaction. This module
owns the parts that must be identical for every client catalog: contract
validation, preflight blocking, and result propagation. It deliberately
returns the existing tester result vocabulary instead of introducing a second
report format.
"""
from __future__ import annotations
from typing import Any, Iterable, Mapping
TEST_TYPES = ("journey", "integration")
CLIENT_SUITES = ("client-smoke", "client-regression", "safety")
INTEGRATION_SUITES = ("api-contract", "authorization", "build-contract")
PLATFORMS = ("android", "ios")
FAILURE_POLICIES = ("stop", "continue")
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
def client_suite(case: Mapping[str, Any]) -> str:
"""Return the canonical client suite, accepting ``suite`` as a migration alias."""
value = case.get("client_suite")
alias = case.get("suite")
if value and alias and str(value) != str(alias):
return ""
return str(value or alias or "")
def integration_suite(case: Mapping[str, Any]) -> str:
"""Return the integration suite, accepting ``suite`` during migration."""
value = case.get("integration_suite")
alias = case.get("suite")
if value and alias and str(value) != str(alias):
return ""
return str(value or alias or "")
def test_type(case: Mapping[str, Any]) -> str:
"""Return an explicit type or infer one for an old catalog entry."""
declared = str(case.get("test_type") or "").strip().lower()
if declared:
return declared
if client_suite(case) or case.get("journey") is not None:
return "journey"
if integration_suite(case):
return "integration"
return ""
def suite(case: Mapping[str, Any]) -> str:
"""Return the report/selection suite for either test type."""
kind = test_type(case)
return client_suite(case) if kind == "journey" else integration_suite(case)
def normalize_platforms(value: Any) -> list[str]:
if isinstance(value, str):
values = [value]
elif isinstance(value, (list, tuple)):
values = list(value)
else:
return []
return [str(item).strip().lower() for item in values if str(item).strip()]
def _non_empty_list(value: Any) -> bool:
return isinstance(value, list) and bool(value) and all(str(item).strip() for item in value)
def _has_account(fixture: Mapping[str, Any]) -> bool:
account = fixture.get("account")
if isinstance(account, Mapping):
return bool(str(account.get("role") or account.get("username") or "").strip())
return bool(str(account or "").strip())
def contract_errors(case: Mapping[str, Any], sessions: Mapping[str, Any] | None = None) -> list[str]:
"""Validate a client journey without requiring app-specific field names.
A case becomes a client journey when it declares ``client_suite`` (or the
temporary ``suite`` alias). Legacy non-client cases keep the existing
schema. Once opted in, all lifecycle inputs are explicit so a missing
fixture/cleanup can stop the run before any case result is fabricated.
"""
kind = test_type(case)
declared_kind = str(case.get("test_type") or "").strip().lower()
if declared_kind and declared_kind not in TEST_TYPES:
return [f"{case.get('id') or '?'}: test_type 必须是 journey 或 integration"]
if not kind:
return []
if kind == "integration":
return integration_contract_errors(case)
suite_name = client_suite(case)
journey = case.get("journey")
errors: list[str] = []
cid = str(case.get("id") or "?")
where = f"{cid}: client journey"
if case.get("client_suite") and case.get("suite") and str(case["client_suite"]) != str(case["suite"]):
errors.append(f"{where}: client_suite 与 suite 不一致")
if suite_name not in CLIENT_SUITES:
errors.append(f"{where}: client_suite 必须是 {', '.join(CLIENT_SUITES)}")
if not isinstance(journey, Mapping):
errors.append(f"{where}: journey 必须是 mapping")
journey = {}
for key in ("goal", "entry_state", "final_state"):
if not str(journey.get(key) or "").strip():
errors.append(f"{where}: journey.{key} 必填")
if not _non_empty_list(journey.get("actions")):
errors.append(f"{where}: journey.actions 必须是非空列表")
if not _non_empty_list(journey.get("assertions")):
errors.append(f"{where}: journey.assertions 必须是非空列表")
policy = str(journey.get("failure_policy") or "")
if policy not in FAILURE_POLICIES:
errors.append(f"{where}: journey.failure_policy 必须是 stop 或 continue")
fixture = case.get("fixture")
if not isinstance(fixture, Mapping):
errors.append(f"{where}: fixture 缺失,必须在 preflight 前声明")
elif not _has_account(fixture):
errors.append(f"{where}: fixture.account 缺失,必须声明测试账号或角色")
cleanup = case.get("cleanup")
if not isinstance(cleanup, Mapping):
errors.append(f"{where}: cleanup 缺失,必须在 preflight 前声明")
else:
steps = cleanup.get("steps")
command = cleanup.get("run")
explicitly_noop = cleanup.get("required") is False and str(cleanup.get("reason") or "").strip()
if not explicitly_noop and not _non_empty_list(steps) and not str(command or "").strip():
errors.append(f"{where}: cleanup 必须有非空 steps/run;只读用例也要显式声明 reason")
platforms = normalize_platforms(case.get("platform"))
if not platforms:
errors.append(f"{where}: platform 必须声明 android/ios")
elif any(platform not in PLATFORMS for platform in platforms):
errors.append(f"{where}: platform 只能是 {', '.join(PLATFORMS)}")
elif len(platforms) != len(set(platforms)):
errors.append(f"{where}: platform 不能重复")
driver = case.get("driver")
if driver not in ("mobile", "browser"):
errors.append(f"{where}: 客户端旅程 driver 必须是 mobile 或 browser")
if driver and driver in ("mobile", "browser"):
session = case.get("session")
if not str(session or "").strip():
errors.append(f"{where}: session 必须声明可复用客户端会话")
elif sessions is not None and session not in sessions:
errors.append(f"{where}: session={session} 未在 catalog.sessions 声明")
if suite_name == "safety" and str(case.get("tag") or "") == "smoke":
errors.append(f"{where}: safety 不得标记为 smoke")
return errors
def integration_contract_errors(case: Mapping[str, Any]) -> list[str]:
"""Validate the non-client half of the tester catalog.
Integration cases deliberately do not require a browser/mobile session. A
``how`` block is the legacy call declaration; newer catalogs may use
``request``/``input``/``call`` or an ``integration`` mapping as well.
``expected`` remains the common protocol/data assertion list.
"""
cid = str(case.get("id") or "?")
where = f"{cid}: integration"
errors: list[str] = []
value = case.get("integration_suite")
alias = case.get("suite")
if value and alias and str(value) != str(alias):
errors.append(f"{where}: integration_suite 与 suite 不一致")
suite_name = integration_suite(case)
if suite_name not in INTEGRATION_SUITES:
errors.append(
f"{where}: integration_suite 必须是 {', '.join(INTEGRATION_SUITES)}"
)
fixture = case.get("fixture")
if not isinstance(fixture, Mapping) or not fixture:
errors.append(f"{where}: fixture 缺失,必须在 preflight 前声明")
declared_call = any(
key in case and case.get(key) not in (None, "", [], {})
for key in ("request", "input", "call", "integration")
)
how = case.get("how")
if not declared_call and not isinstance(how, Mapping):
errors.append(f"{where}: 必须声明 request/input/call/integration 或 how")
# Integration writes need an explicit rollback/cleanup contract. Read-only
# probes may state why cleanup is unnecessary, matching test-preflight.
risk = str(case.get("risk") or "")
cleanup = case.get("cleanup")
cleanup_steps = isinstance(cleanup, Mapping) and (
bool(cleanup.get("steps")) or bool(str(cleanup.get("run") or "").strip())
)
cleanup_noop = isinstance(cleanup, Mapping) and cleanup.get("required") is False and bool(
str(cleanup.get("reason") or "").strip()
)
if risk != "readonly" and not (cleanup_steps or cleanup_noop):
errors.append(f"{where}: 写入型 integration 必须声明 cleanup/rollback steps/run")
elif cleanup is not None and not (cleanup_steps or cleanup_noop):
errors.append(f"{where}: cleanup 必须有 steps/run 或 required=false + reason")
forbidden = []
if case.get("client_suite") or case.get("journey") is not None:
forbidden.append("client_suite/journey")
if case.get("session") or case.get("platform"):
forbidden.append("session/platform")
if forbidden:
errors.append(f"{where}: integration 不应声明 " + ", ".join(forbidden))
return errors
def preflight(cases: Iterable[Mapping[str, Any]], sessions: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""Return a contract preflight result; no per-case result is created on block."""
cases = list(cases)
errors: list[str] = []
for case in cases:
errors.extend(contract_errors(case, sessions=sessions))
return {
"required": bool(errors or any(client_suite(case) for case in cases)),
"status": "blocked" if errors else "ready",
"failure_class": "contract" if errors else None,
"errors": errors,
}
def classify_outcome(
*,
ok: bool,
environment_ready: bool = True,
phase: str = "journey",
) -> str:
"""Classify one observed phase using the tester's five result states.
A valid test account is part of the client journey: a failed login is a
product ``fail``. Only an unavailable environment is ``environment-fail``.
"""
if not environment_ready:
return "environment-fail"
if not ok:
return "fail"
return "pass"
def propagate_results(
case_ids: Iterable[str],
observed: Mapping[str, str],
*,
skipped: Mapping[str, str] | None = None,
continue_on_fail: bool = False,
policies: Mapping[str, str] | None = None,
blocked_by: Mapping[str, Iterable[str]] | None = None,
) -> list[dict[str, str]]:
"""Build ordered result rows and mark cases after a blocking failure not-run.
``skipped`` is intentional selection (risk/lifecycle/assumption); it is
never converted to ``not-run``. A product or environment failure stops
subsequent cases unless the caller explicitly opts into continuation.
"""
skipped = skipped or {}
policies = policies or {}
blocked_by = blocked_by or {}
rows: list[dict[str, str]] = []
blocking_case = ""
for raw_id in case_ids:
case_id = str(raw_id)
if case_id in skipped:
rows.append({"id": case_id, "result": "skipped", "notes": skipped[case_id]})
continue
if blocking_case:
rows.append({
"id": case_id,
"result": "not-run",
"notes": "前置用例失败,未轮到执行",
"blocked_by": ",".join(blocked_by.get(case_id) or (blocking_case,)),
})
continue
result = observed.get(case_id, "not-run")
if result not in RESULTS:
raise ValueError(f"未知结果: {case_id}={result}")
rows.append({"id": case_id, "result": result})
policy = policies.get(case_id)
if result in ("fail", "environment-fail") and not (continue_on_fail or policy == "continue"):
blocking_case = case_id
return rows
+777
View File
@@ -0,0 +1,777 @@
#!/usr/bin/env python3
"""Provision caller-provided mobile artifacts for tester.
This is shared tester infrastructure. It never replaces real Android/iOS
execution with HTTP calls: it only obtains, verifies and installs the
artifact the caller supplied, then the catalog runner drives the mobile
session.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import plistlib
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from platform import system as platform_system
from typing import Any
COMMIT = re.compile(r"^[0-9a-fA-F]{40}$")
PLATFORMS = {"android", "ios"}
PREFLIGHT_ROOT = Path(__file__).resolve().parents[2] / "test-preflight" / "scripts" / "preflight.py"
class EnvironmentFailure(RuntimeError):
"""An external environment or recoverable provisioning failure."""
class ArtifactFailure(RuntimeError):
"""A missing or unusable caller-provided artifact locator."""
@dataclass
class CommandResult:
code: int
output: str
@dataclass
class RuntimeContext:
root: Path
bindings: dict[str, str] = field(default_factory=dict)
retries: dict[str, int] = field(default_factory=lambda: {"download": 3, "start": 3, "install": 3})
evidence: list[dict[str, Any]] = field(default_factory=list)
def record(self, command: str, result: CommandResult) -> None:
self.evidence.append({
"command": command,
"status": "ok" if result.code == 0 else "error",
"output": result.output[-800:],
})
def redact_url(value: str) -> str:
parsed = urllib.parse.urlsplit(value)
if not parsed.query:
return value
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "<redacted>", ""))
def run_command(args: list[str], *, context: RuntimeContext | None = None, cwd: Path | None = None) -> CommandResult:
try:
proc = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False)
result = CommandResult(proc.returncode, proc.stdout.strip())
except OSError as exc:
result = CommandResult(127, str(exc))
if context:
context.record(" ".join(args), result)
return result
def fetch_bytes(url: str, *, attempts: int = 3, token: str = "") -> bytes:
if url.startswith("file://"):
return Path(urllib.parse.urlsplit(url).path).read_bytes()
if not urllib.parse.urlsplit(url).scheme:
return Path(url).read_bytes()
last: Exception | None = None
for attempt in range(1, attempts + 1):
try:
headers = {"User-Agent": "pouch-tester/1"}
if token:
headers["Authorization"] = f"token {token}"
request = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(request, timeout=30) as response:
return response.read()
except (OSError, urllib.error.URLError) as exc:
last = exc
if attempt < attempts:
time.sleep(min(attempt, 2))
raise EnvironmentFailure(f"下载失败({attempts} 次): {redact_url(url)}: {last}")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def parse_artifacts(values: list[str]) -> dict[str, str]:
pairs: dict[str, str] = {}
for raw in values:
platform, sep, value = raw.partition("=")
platform = platform.strip().lower()
value = value.strip()
if not sep or platform not in PLATFORMS or not value:
raise ArtifactFailure(f"--artifact 格式必须是 android=<路径或 URL> 或 ios=<路径或 URL>: {raw}")
if platform in pairs:
raise ArtifactFailure(f"--artifact 重复平台: {platform}")
pairs[platform] = value
return pairs
def parse_bindings(values: list[str], requested: list[str]) -> dict[str, str]:
"""Resolve one tester binding per requested platform.
A single legacy ``agent`` mapping cannot safely represent a multi-platform
run. Keep the platform distinction in the command contract all the way to
agent-runtime instead of treating binding as report metadata.
"""
pairs: dict[str, str] = {}
for raw in values:
platform, sep, value = raw.partition("=")
platform = platform.strip().lower()
value = value.strip()
if not sep or platform not in PLATFORMS or not value or not re.fullmatch(r"[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+", value):
raise ArtifactFailure(f"--binding 格式必须是 android=tester.<name> 或 ios=tester.<name>: {raw}")
if platform in pairs:
raise ArtifactFailure(f"--binding 重复平台: {platform}")
pairs[platform] = value
resolved = {platform: pairs.get(platform, f"tester.{platform}") for platform in requested}
if len(set(resolved.values())) != len(resolved) and len(resolved) > 1:
raise ArtifactFailure("多平台运行不能复用同一个 tester binding;请分别配置 tester.android/tester.ios")
return resolved
def load_agent_runtime():
candidates = [
Path.home() / ".codex" / "skills" / "agent-runtime" / "scripts" / "agent.py",
Path.home() / ".pouch" / "skills" / "agent-runtime" / "scripts" / "agent.py",
Path(__file__).resolve().parents[2] / "agent-runtime" / "scripts" / "agent.py",
]
runtime_path = next((item for item in candidates if item.is_file()), None)
if runtime_path is None:
raise EnvironmentFailure("找不到共享 agent-runtime: " + ", ".join(str(item) for item in candidates))
spec = importlib.util.spec_from_file_location("pouch_agent_runtime_for_mobile_bundle", runtime_path)
if spec is None or spec.loader is None:
raise EnvironmentFailure(f"无法加载共享 agent-runtime: {runtime_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def binding_location(root: Path, binding_id: str) -> dict[str, Any]:
runtime = load_agent_runtime()
try:
value = runtime.binding(runtime.load_config(root), binding_id)
except (ValueError, OSError) as exc:
raise EnvironmentFailure(f"无法解析 {binding_id}: {exc}") from exc
if value.get("adapter") != "tester":
raise EnvironmentFailure(f"{binding_id} 不是 tester binding")
return value
def agent_runner(root: Path, request: dict[str, Any]) -> dict[str, Any]:
"""Dispatch a platform operation through the resolved tester binding."""
runtime = load_agent_runtime()
return runtime.execute_runner_request(root, request)
def platform_node(root: Path, binding_id: str) -> str:
location = binding_location(root, binding_id)["location"]
return "local" if location.get("environment") == "local" else str(location.get("environment"))
def validate_preflight_matrix(root: Path, config_path: Path, bindings: dict[str, str], requested: list[str]) -> None:
"""Fail closed when a matrix routes a platform to the wrong node/runner."""
try:
import yaml
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (OSError, ValueError, ImportError) as exc:
raise EnvironmentFailure(f"无法读取 preflight matrix: {config_path}: {exc}") from exc
rows = raw.get("matrix") if isinstance(raw, dict) else None
if not isinstance(rows, list):
raise EnvironmentFailure("preflight matrix 缺少 matrix;不能推断平台执行节点")
by_platform = {str(row.get("platform", "")).lower(): row for row in rows if isinstance(row, dict)}
for platform in requested:
expected_binding = bindings[platform]
row = by_platform.get(platform)
if not row:
raise EnvironmentFailure(f"preflight matrix 缺少 {platform} 行;不能创建该平台 session")
tester = row.get("tester") if isinstance(row, dict) else None
actual_binding = tester.get("binding") if isinstance(tester, dict) else None
if actual_binding != expected_binding:
raise EnvironmentFailure(f"{platform} preflight binding={actual_binding!r},期望 {expected_binding!r}")
expected_node = platform_node(root, expected_binding)
actual_node = tester.get("node") if isinstance(tester, dict) else None
if expected_node == "local" and actual_node not in {"local", "localhost", "127.0.0.1"}:
raise EnvironmentFailure(f"{platform} binding 是 local,但 preflight node={actual_node!r}")
runner = tester.get("runner") if isinstance(tester, dict) else None
if expected_node != "local" and (not isinstance(runner, dict) or runner.get("kind") != "contract"):
raise EnvironmentFailure(f"{platform} 绑定到 {expected_node},必须使用 contract runner;禁止在协调节点执行")
def preflight_remote_artifacts(config_path: Path, requested: list[str]) -> dict[str, str]:
"""Return caller-declared artifact paths readable by remote tester nodes."""
try:
import yaml
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (OSError, ValueError, ImportError) as exc:
raise EnvironmentFailure(f"无法读取 preflight matrix: {config_path}: {exc}") from exc
rows = raw.get("matrix") if isinstance(raw, dict) else None
if not isinstance(rows, list):
raise EnvironmentFailure("preflight matrix 缺少 matrix;不能解析远程产物路径")
by_platform = {str(row.get("platform", "")).lower(): row for row in rows if isinstance(row, dict)}
paths: dict[str, str] = {}
for platform in requested:
row = by_platform.get(platform) or {}
artifact = row.get("artifact") if isinstance(row, dict) else None
remote_path = artifact.get("remote_path") if isinstance(artifact, dict) else None
if isinstance(remote_path, str) and remote_path.strip():
paths[platform] = remote_path.strip()
return paths
def local_path(locator: str) -> Path | None:
parsed = urllib.parse.urlsplit(locator)
if parsed.scheme in ("http", "https"):
return None
if parsed.scheme == "file":
return Path(parsed.path)
if parsed.scheme:
raise ArtifactFailure(f"不支持的产物地址 scheme: {parsed.scheme}")
return Path(locator).expanduser()
def obtain_artifact(platform: str, locator: str, destination: Path, context: RuntimeContext) -> dict[str, Any]:
path = local_path(locator)
if path is not None:
if not path.is_file():
raise ArtifactFailure(f"{platform} 产物不是现有文件: {path}")
resolved = path.resolve()
return {
"path": str(resolved),
"locator": str(resolved),
"sha256": sha256_file(resolved),
"attempts": 1,
"source": "file",
"verified": True,
}
last: Exception | None = None
for attempt in range(1, context.retries["download"] + 1):
try:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(fetch_bytes(locator, attempts=1, token=os.environ.get("GITEA_TOKEN", "")))
return {
"path": str(destination),
"locator": redact_url(locator),
"sha256": sha256_file(destination),
"attempts": attempt,
"source": "url",
"verified": True,
}
except (OSError, EnvironmentFailure) as exc:
last = exc
if attempt < context.retries["download"]:
time.sleep(min(attempt, 2))
raise EnvironmentFailure(f"{platform} 下载失败({context.retries['download']} 次): {last}")
def catalog_identities(root: Path) -> dict[str, str]:
path = root / ".pouch" / "tester" / "catalog.yaml"
if not path.is_file():
return {}
try:
import yaml
except ImportError:
return {}
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError):
return {}
artifacts = data.get("artifacts")
if not isinstance(artifacts, dict):
return {}
identities: dict[str, str] = {}
android = artifacts.get("android")
ios = artifacts.get("ios")
if isinstance(android, dict) and isinstance(android.get("package"), str) and android["package"]:
identities["android"] = android["package"]
if isinstance(ios, dict) and isinstance(ios.get("bundle_id"), str) and ios["bundle_id"]:
identities["ios"] = ios["bundle_id"]
return identities
def android_identity(path: Path, context: RuntimeContext) -> str:
commands: list[list[str]] = []
if shutil.which("apkanalyzer"):
commands.append(["apkanalyzer", "manifest", "application-id", str(path)])
if shutil.which("aapt2"):
commands.append(["aapt2", "dump", "badging", str(path)])
if shutil.which("aapt"):
commands.append(["aapt", "dump", "badging", str(path)])
if not commands:
raise EnvironmentFailure("缺少 apkanalyzer/aapt2/aapt;请在 Android tester 节点配置 Android SDK build-tools")
for command in commands:
result = run_command(command, context=context)
if result.code == 0:
match = re.search(r"(?:name|application-id)[=:]['\"]?([A-Za-z0-9_.]+)", result.output)
if not match:
# apkanalyzer manifest application-id prints the bare package
# name, while aapt/aapt2 print a package: name=... record.
match = re.fullmatch(r"\s*([A-Za-z0-9_.]+)\s*", result.output)
if match:
return match.group(1)
raise EnvironmentFailure("APK 身份读取失败;请检查 Android SDK build-tools")
def ios_identity(path: Path) -> str:
try:
with zipfile.ZipFile(path) as archive:
candidates = [name for name in archive.namelist() if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)]
if len(candidates) != 1:
raise EnvironmentFailure("IPA 中没有唯一 Payload/*.app/Info.plist")
plist = plistlib.loads(archive.read(candidates[0]))
except (OSError, zipfile.BadZipFile, plistlib.InvalidFileException) as exc:
raise EnvironmentFailure(f"IPA 解包/读取 Info.plist 失败: {exc}") from exc
bundle_id = plist.get("CFBundleIdentifier")
if not isinstance(bundle_id, str) or not bundle_id:
raise EnvironmentFailure("IPA Info.plist 缺少 CFBundleIdentifier")
return bundle_id
def retry_command(args: list[str], *, context: RuntimeContext, action: str) -> CommandResult:
last = CommandResult(1, "")
for attempt in range(1, context.retries[action] + 1):
last = run_command(args, context=context)
if last.code == 0:
return last
if attempt < context.retries[action]:
time.sleep(min(attempt, 2))
raise EnvironmentFailure(f"{action} 失败({context.retries[action]} 次): {' '.join(args)}\n{last.output[-500:]}")
def android_preflight(context: RuntimeContext) -> dict[str, str]:
adb = os.environ.get("ADB_BIN") or shutil.which("adb")
if not adb:
raise EnvironmentFailure("Android 节点缺少 adb;请安装/配置 Android SDK platform-tools")
retry_command([adb, "start-server"], context=context, action="start")
devices = run_command([adb, "devices", "-l"], context=context)
available = [line.split()[0] for line in devices.output.splitlines()[1:] if re.search(r"\sdevice\s", line)]
serial = os.environ.get("ANDROID_SERIAL", "").strip()
if serial and serial not in available:
raise EnvironmentFailure(f"ANDROID_SERIAL={serial} 不在 device 状态;请解锁/授权设备后检查 adb devices -l")
if not serial and len(available) != 1:
if not available:
raise EnvironmentFailure("没有 Android device;请连接、解锁、开启 USB 调试并接受 RSA 授权")
raise EnvironmentFailure(f"检测到多个 Android device ({', '.join(available)});请设置 ANDROID_SERIAL")
return {"adb": adb, "serial": serial or available[0], "session_lifecycle": "reused-or-started"}
def ios_preflight(context: RuntimeContext) -> dict[str, str]:
if platform_system() != "Darwin":
raise EnvironmentFailure("iOS 必须由 tester.ios 的 macOS 节点执行;Linux 不能伪装 Simulator")
xcrun = shutil.which("xcrun")
if not xcrun:
raise EnvironmentFailure("iOS 节点缺少 xcrun;请安装 Xcode Command Line Tools")
devices = run_command([xcrun, "simctl", "list", "devices", "available"], context=context)
if devices.code != 0:
raise EnvironmentFailure("simctl 不可用;请安装 Xcode 并确认已接受许可")
wanted = os.environ.get("IOS_SIMULATOR_UDID", "").strip()
matches: list[tuple[str, str, str]] = []
for line in devices.output.splitlines():
match = re.search(r"^\s*(.+?)\s+\(([0-9A-Fa-f-]{36})\)\s+\((Booted|Shutdown)\)", line)
if match:
matches.append((match.group(2), match.group(1), match.group(3)))
selected = next((item for item in matches if item[0] == wanted), None) if wanted else None
selected = selected or next((item for item in matches if item[2] == "Booted"), None) or (matches[0] if matches else None)
if not selected:
raise EnvironmentFailure("没有可用 iOS Simulator;请在 Mac 上安装一个可用 runtime/device")
udid, name, state = selected
if state != "Booted":
retry_command([xcrun, "simctl", "boot", udid], context=context, action="start")
retry_command([xcrun, "simctl", "bootstatus", udid, "-b"], context=context, action="start")
return {"xcrun": xcrun, "udid": udid, "name": name, "session_lifecycle": "reused" if state == "Booted" else "started"}
def check_network(context: RuntimeContext, node: dict[str, str] | None = None) -> None:
host = os.environ.get("HOST", "").strip()
api = os.environ.get("API_BASE", "").strip() or (f"http://{host}" if host else "")
if not api:
raise EnvironmentFailure("缺少 HOST/API_BASE;请在 tester.local.yaml 配置测试服务地址")
for attempt in range(1, context.retries["start"] + 1):
try:
with urllib.request.urlopen(urllib.request.Request(api.rstrip("/") + "/v1/me"), timeout=5) as response:
if response.status < 500:
break
except urllib.error.HTTPError as exc:
if exc.code < 500:
break
except (OSError, urllib.error.URLError):
pass
if attempt == context.retries["start"]:
raise EnvironmentFailure(f"测试服务不可达: {api};请检查 HOST、VPN、DNS 和局域网后重跑")
time.sleep(min(attempt, 2))
if node and "adb" in node:
if shutil.which("ping"):
result = run_command([node["adb"], "-s", node["serial"], "shell", "ping", "-c", "1", "-W", "3", host], context=context)
if result.code != 0:
raise EnvironmentFailure(f"Android device 无法访问 HOST={host};请检查设备 Wi-Fi/DNS/VPN")
elif node:
result = run_command([node["xcrun"], "simctl", "spawn", node["udid"], "/usr/bin/curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", api.rstrip("/") + "/v1/me"], context=context)
if result.code != 0 or result.output.strip() in {"", "000"} or result.output.strip().startswith("5"):
raise EnvironmentFailure(f"iOS Simulator 无法访问 {api};请检查 Mac 的 VPN/DNS/局域网权限")
def install_android(node: dict[str, str], path: Path, package: str, context: RuntimeContext) -> dict[str, Any]:
retry_command([node["adb"], "-s", node["serial"], "install", "-r", str(path)], context=context, action="install")
result = run_command([node["adb"], "-s", node["serial"], "shell", "pm", "path", package], context=context)
if result.code != 0 or "package:" not in result.output:
raise EnvironmentFailure(f"安装后未找到 Android package {package};请检查签名/设备空间")
return {"serial": node["serial"], "package": package, "installed": True, "verified": True}
def install_ios(node: dict[str, str], path: Path, bundle_id: str, context: RuntimeContext) -> dict[str, Any]:
retry_command([node["xcrun"], "simctl", "install", node["udid"], str(path)], context=context, action="install")
result = run_command([node["xcrun"], "simctl", "get_app_container", node["udid"], bundle_id], context=context)
if result.code != 0 or not result.output:
raise EnvironmentFailure(f"安装后未找到 iOS bundle {bundle_id};请检查签名/Simulator 架构")
return {"udid": node["udid"], "simulator": node["name"], "bundle_id": bundle_id, "installed": True, "verified": True}
def sanitize(value: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "-", value)
def resolve_commit(explicit: str, context: RuntimeContext) -> str:
if explicit:
if not COMMIT.fullmatch(explicit):
raise ArtifactFailure("--commit 必须是 40 位 commit SHA")
return explicit
git = shutil.which("git") or "git"
result = run_command([git, "rev-parse", "HEAD"], context=context, cwd=context.root)
if result.code != 0 or not COMMIT.fullmatch(result.output):
raise EnvironmentFailure(f"无法读取当前 commit: {context.root}")
return result.output
def ensure_worktree(commit: str, context: RuntimeContext, parent: Path | None = None) -> tuple[Path, str]:
git = shutil.which("git") or "git"
check = run_command([git, "rev-parse", "--show-toplevel"], context=context, cwd=context.root)
if check.code != 0:
raise EnvironmentFailure(f"目标目录不是 Git worktree: {context.root}")
repo = Path(check.output).resolve()
verify = run_command([git, "rev-parse", f"{commit}^{{commit}}"], context=context, cwd=repo)
if verify.code != 0:
fetched = run_command([git, "fetch", "--no-tags", "origin", commit], context=context, cwd=repo)
if fetched.code != 0:
raise EnvironmentFailure(f"目标 commit 不在本地且无法从 origin 获取: {commit}")
base = parent or Path(os.environ.get("TESTER_WORKTREE_ROOT", Path(tempfile.gettempdir()) / "pouch-tester" / "worktrees"))
base.mkdir(parents=True, exist_ok=True)
token = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + os.urandom(3).hex()
path = (base / f"{sanitize(commit[:12])}-{token}").resolve()
if path.exists():
raise EnvironmentFailure(f"隔离 worktree 路径已存在,拒绝覆盖: {path}")
added = run_command([git, "worktree", "add", "--detach", str(path), commit], context=context, cwd=repo)
if added.code != 0:
raise EnvironmentFailure(f"创建精确 commit worktree 失败: {path}\n{added.output[-500:]}")
actual = run_command([git, "-C", str(path), "rev-parse", "HEAD"], context=context)
if actual.code != 0 or actual.output.lower() != commit.lower():
raise EnvironmentFailure(f"worktree commit 不匹配: expected={commit} actual={actual.output}")
return path, actual.output
def platforms(raw: str) -> list[str]:
values = [item.strip().lower() for item in raw.split(",") if item.strip()]
if not values:
return ["android", "ios"]
if any(item not in PLATFORMS for item in values) or len(values) != len(set(values)):
raise ArtifactFailure("--platform 只能是 android,ios 且不能重复")
return values
def write_run(root: Path, record: dict[str, Any]) -> Path:
runs = root / ".pouch" / "tester" / "runs"
runs.mkdir(parents=True, exist_ok=True)
run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-bundle-" + os.urandom(3).hex()
path = runs / f"{run_id}.yaml"
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def all_active_result_rows(root: Path, *, blocked_by: str) -> list[dict[str, Any]]:
"""Keep a testcase row for every active catalog entry when preflight blocks."""
try:
from generate_cases_csv import load_cases
cases = load_cases(root)
except (ImportError, OSError, ValueError, SystemExit):
cases = []
rows: list[dict[str, Any]] = []
for case in cases:
if case.get("lifecycle", "active") != "active":
continue
rows.append({
"id": str(case.get("id") or ""),
"result": "not-run",
"notes": "test-preflight 未通过,未创建移动 session",
"blocked_by": [blocked_by],
})
return rows
def run_test_preflight(
config_path: Path,
*,
mode: str = "verify",
yaml_path: Path | None = None,
csv_path: Path | None = None,
) -> dict[str, Any]:
"""Run the shared preflight contract before any client session work."""
if mode not in {"verify", "prepare"}:
raise ArtifactFailure("preflight mode 只能是 verify 或 prepare")
if not PREFLIGHT_ROOT.is_file():
raise EnvironmentFailure(f"找不到 test-preflight skill: {PREFLIGHT_ROOT}")
spec = importlib.util.spec_from_file_location("pouch_test_preflight", PREFLIGHT_ROOT)
if spec is None or spec.loader is None:
raise EnvironmentFailure("无法加载 test-preflight skill")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
try:
config = module.load_config(config_path)
evidence = module.run(config, mode)
yaml_path = yaml_path or config_path.with_name("preflight.yaml")
csv_path = csv_path or config_path.with_name("preflight.csv")
return module.write_evidence(evidence, yaml_path, csv_path)
except (OSError, ValueError, module.yaml.YAMLError) as exc:
return {"status": "blocked", "error": str(exc), "yaml": str(yaml_path or ""), "csv": str(csv_path or "")}
def execute(args: argparse.Namespace) -> int:
requested = platforms(args.platform)
root = Path(args.root).resolve()
bindings = parse_bindings(args.bindings, requested)
context = RuntimeContext(root, bindings=bindings)
record: dict[str, Any] = {
"schema_version": 1,
"started_at": datetime.now(timezone.utc).isoformat(),
"platforms_requested": requested,
"partial": bool(args.partial),
"bindings": bindings,
"node": os.environ.get("TESTER_NODE", "local"),
"terminal": {"handle": os.environ.get("ORCA_TERMINAL_HANDLE"), "created": os.environ.get("TESTER_TERMINAL_CREATED", "false").lower() == "true", "closed": os.environ.get("TESTER_TERMINAL_CLOSED", "unknown")},
"product_test": {"status": "not-run", "failure_class": "automatic-repair-failed", "note": "准备完成后由 tester catalog 驱动真实移动 session;本工具不以 API 替代移动测试"},
"platforms": {},
}
remote_artifacts: dict[str, str] = {}
try:
locators = parse_artifacts(args.artifacts)
missing = [platform for platform in requested if platform not in locators]
extra = [platform for platform in locators if platform not in requested]
if missing:
raise ArtifactFailure("缺少调用方产物位置: " + ", ".join(f"--artifact {item}=<路径或 URL>" for item in missing))
if extra:
raise ArtifactFailure("产物平台超出 --platform: " + ", ".join(extra))
record["artifacts_requested"] = {platform: redact_url(locator) if local_path(locator) is None else locator for platform, locator in locators.items()}
commit = resolve_commit(args.commit, context)
worktree, actual_commit = ensure_worktree(commit, context, Path(args.worktree_root).resolve() if args.worktree_root else None)
record["worktree"] = {"path": str(worktree), "expected_commit": commit, "actual_commit": actual_commit, "isolated": True}
artifact_dir = Path(tempfile.mkdtemp(prefix="pouch-tester-artifacts-"))
record["artifact_dir"] = str(artifact_dir)
expected = catalog_identities(context.root)
prepared: dict[str, tuple[dict[str, Any], dict[str, str] | None, str]] = {}
failures: dict[str, str] = {}
for platform in requested:
result: dict[str, Any] = {"node": "tester.android" if platform == "android" else "tester.ios", "status": "preparing"}
record["platforms"][platform] = result
try:
path = artifact_dir / ("app.apk" if platform == "android" else "app.ipa")
result["artifact"] = obtain_artifact(platform, locators[platform], path, context)
identity = android_identity(Path(result["artifact"]["path"]), context) if platform == "android" else ios_identity(Path(result["artifact"]["path"]))
wanted = expected.get(platform)
if wanted and identity != wanted:
raise EnvironmentFailure(f"{platform} 身份不匹配: expected={wanted} actual={identity}")
result.update({"identity": identity, "status": "artifact-ready"})
prepared[platform] = (result, None, identity)
except (EnvironmentFailure, ArtifactFailure) as exc:
failures[platform] = str(exc)
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
# This is the hard gate shared by integration and journey callers.
# There is no safe implicit matrix: without a caller-supplied config we
# cannot prove HOST, fixtures, tester bindings, or node reachability.
preflight_config = Path(args.preflight_config).expanduser() if args.preflight_config else None
if not failures:
if preflight_config is None:
preflight_result = {
"status": "blocked",
"error": "缺少 --preflight-config;不能跳过 test-preflight",
}
else:
validate_preflight_matrix(context.root, preflight_config, bindings, requested)
remote_artifacts = preflight_remote_artifacts(preflight_config, requested)
preflight_result = run_test_preflight(
preflight_config,
mode=args.preflight_mode,
yaml_path=Path(args.preflight_yaml).expanduser() if args.preflight_yaml else None,
csv_path=Path(args.preflight_csv).expanduser() if args.preflight_csv else None,
)
record["preflight"] = preflight_result
if preflight_result.get("status") != "ready":
failures["preflight"] = str(preflight_result.get("error") or preflight_result.get("status"))
if not failures and args.preflight_mode == "prepare":
# test-preflight prepare owns platform probing, installation and
# launch; do not install/start the same artifact a second time.
for result, _, _ in prepared.values():
result.update({"status": "preflight-prepared"})
elif not failures:
for platform in requested:
result, _, identity = prepared[platform]
try:
binding_id = bindings[platform]
node_name = platform_node(context.root, binding_id)
probe = agent_runner(context.root, {
"operation": "platform_probe",
"platform": platform,
"node": node_name,
"binding": binding_id,
})
if probe.get("status") != "ready":
raise EnvironmentFailure(str(probe.get("observed") or probe))
host = os.environ.get("HOST", "").strip()
network = agent_runner(context.root, {
"operation": "network_probe",
"platform": platform,
"node": node_name,
"binding": binding_id,
"url": f"http://{host}/v1/me",
}) if host else {"status": "blocked", "observed": "HOST 未设置"}
if network.get("status") != "ready":
raise EnvironmentFailure(str(network.get("observed") or network))
result.update({"node_preflight": probe, "network_preflight": network, "status": "preflight-passed"})
prepared[platform] = (result, None, identity)
except EnvironmentFailure as exc:
failures[platform] = str(exc)
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
if failures and not args.partial:
for platform in requested:
if platform not in failures:
record["platforms"][platform].update({"status": "not-run", "reason": "全量 preflight 闸门被另一平台阻断"})
overall = record.get("preflight", {}).get("status") if "preflight" in record else "environment-fail"
record.update({"status": overall if overall in {"blocked", "environment-fail"} else "environment-fail", "failure_class": "test-preflight" if "preflight" in record else "automatic-repair-failed", "next_action": "修复列出的平台环境后重跑;默认不会静默降级"})
record["results"] = all_active_result_rows(context.root, blocked_by="test-preflight")
else:
for platform, (result, node, identity) in prepared.items():
try:
if args.preflight_mode == "prepare":
# test-preflight already ran platform_probe, network_probe,
# install and start through each matrix binding.
installed = {"delegated": "test-preflight.prepare", "binding": bindings[platform]}
else:
binding_id = bindings[platform]
node_name = platform_node(context.root, binding_id)
probe = agent_runner(context.root, {
"operation": "platform_probe",
"platform": platform,
"node": node_name,
"binding": binding_id,
})
if probe.get("status") != "ready":
raise EnvironmentFailure(str(probe.get("observed") or probe))
host = os.environ.get("HOST", "").strip()
if host:
network = agent_runner(context.root, {
"operation": "network_probe",
"platform": platform,
"node": node_name,
"binding": binding_id,
"url": f"http://{host}/v1/me",
})
if network.get("status") != "ready":
raise EnvironmentFailure(str(network.get("observed") or network))
artifact_path = result["artifact"]["path"]
if node_name != "local":
artifact_path = remote_artifacts.get(platform, "")
if not artifact_path:
raise EnvironmentFailure(f"远程 {platform} 安装必须在 matrix.artifact.remote_path 声明节点可读产物")
installed = agent_runner(context.root, {
"operation": "install",
"platform": platform,
"node": node_name,
"binding": binding_id,
"artifact": artifact_path,
"identity": identity,
})
if installed.get("status") != "ready":
raise EnvironmentFailure(str(installed.get("observed") or installed))
result.update({"status": "ready", "install": installed, "failure_class": None})
except EnvironmentFailure as exc:
failures[platform] = str(exc)
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
record.update({"status": "environment-fail" if failures else "ready", "failure_class": "automatic-repair-failed" if failures else None})
if not failures:
record["product_test"].update({"status": "pending", "failure_class": None})
record["evidence"] = context.evidence
except (EnvironmentFailure, ArtifactFailure) as exc:
record.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc), "evidence": context.evidence})
if record.get("status") != "ready" and "results" not in record:
record["results"] = all_active_result_rows(context.root, blocked_by="test-preflight")
path = write_run(context.root, record)
try:
from generate_run_csv import export_run_csv
record["csv"] = str(export_run_csv(context.root, run_path=path))
except (OSError, SystemExit, ValueError):
# The YAML run remains the source of truth when a partial/provisioning
# root has no catalog cases yet.
record["csv"] = ""
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
output = {"status": record["status"], "run": str(path), "platforms": record["platforms"], "worktree": record.get("worktree")}
if record.get("error"):
output["error"] = record["error"]
if record.get("next_action"):
output["next_action"] = record["next_action"]
print(json.dumps(output, ensure_ascii=False, indent=2), file=sys.stderr if record["status"] != "ready" else sys.stdout)
return 0 if record["status"] == "ready" else 1
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(prog="tester", description="Obtain and provision caller-provided mobile artifacts")
sub = root.add_subparsers(dest="command", required=True)
run = sub.add_parser("run")
run.add_argument("--artifact", action="append", dest="artifacts", default=[], metavar="PLATFORM=PATH_OR_URL", help="调用方产物,可重复,例如 android=dist/app.apk 或 ios=https://...")
run.add_argument("--platform", default="android,ios")
run.add_argument("--commit", default="", help="本轮隔离 worktree 的 40 位 commit;缺省使用当前 HEAD")
run.add_argument("--partial", action="store_true")
run.add_argument("--root", default=".")
run.add_argument("--worktree-root", default="")
run.add_argument(
"--binding",
action="append",
dest="bindings",
default=[],
metavar="PLATFORM=TESTER_BINDING",
help="每个平台的 tester binding,可重复;默认 tester.<platform>",
)
run.add_argument("--preflight-config", default="", help="test-preflight matrix YAML(必填,不得跳过)")
run.add_argument("--preflight-mode", choices=("verify", "prepare"), default="verify")
run.add_argument("--preflight-yaml", default="", help="preflight YAML 证据输出路径")
run.add_argument("--preflight-csv", default="", help="preflight CSV 证据输出路径")
run.set_defaults(func=execute)
return root
if __name__ == "__main__":
parsed = parser().parse_args()
raise SystemExit(parsed.func(parsed))
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Append structured tester issue evidence to an existing run snapshot."""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any
import yaml
from failure_feedback import attach_feedback
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="将 tester 失败结构化回流到 run YAML/CSV")
parser.add_argument("--run", required=True, type=Path)
parser.add_argument("--trigger", choices=("pr", "requirement", "bug"))
parser.add_argument("--target", default="")
parser.add_argument("--related-issue", default="")
parser.add_argument("--dispatcher", default="dispatcher")
parser.add_argument("--testcase", default="", help="为一条失败结果补充 testcase issue 字段")
parser.add_argument("--issue-type", choices=("product", "test"))
parser.add_argument("--expected", default="")
parser.add_argument("--observed", default="")
parser.add_argument("--artifact-hash", default="")
parser.add_argument("--environment", default="")
parser.add_argument("--evidence-path", default="")
parser.add_argument("--reproduction", default="")
parser.add_argument("--next-step", default="")
args = parser.parse_args(argv)
data: dict[str, Any] = yaml.safe_load(args.run.read_text(encoding="utf-8")) or {}
if args.testcase:
rows = data.get("results")
if not isinstance(rows, list):
raise SystemExit("run 缺少 results 列表")
row = next((item for item in rows if isinstance(item, dict) and str(item.get("id")) == args.testcase), None)
if row is None:
raise SystemExit(f"找不到 testcase: {args.testcase}")
row["issue"] = {
key: value
for key, value in {
"issue_type": args.issue_type,
"expected": args.expected,
"observed": args.observed,
"artifact_hash": args.artifact_hash,
"environment": args.environment,
"evidence_path": args.evidence_path,
"reproduction": args.reproduction,
"next_step": args.next_step,
}.items()
if value
}
output = attach_feedback(
data,
trigger=args.trigger,
target=args.target,
related_issue=args.related_issue,
dispatcher=args.dispatcher,
)
args.run.write_text(yaml.safe_dump(output, allow_unicode=True, sort_keys=False), encoding="utf-8")
csv_path = ""
try:
from generate_run_csv import export_run_csv
csv_path = str(export_run_csv(args.run.parents[3], run_path=args.run))
except (ImportError, OSError, SystemExit, ValueError):
pass
print(f"updated {args.run} defects={len(output.get('defects') or [])} csv={csv_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8 -3
View File
@@ -180,9 +180,14 @@ def clean_config(config: dict[str, Any]) -> dict[str, Any]:
def config_from_binding(root: Path, selector: str) -> dict[str, Any]:
"""Materialize a common agent-runtime tester binding for legacy local.yaml."""
runtime_path = Path(__file__).resolve().parents[3] / "skills" / "agent-runtime" / "scripts" / "agent.py"
if not runtime_path.is_file():
raise RuntimeError(f"找不到共享 agent-runtime: {runtime_path}")
candidates = [
Path.home() / ".codex" / "skills" / "agent-runtime" / "scripts" / "agent.py",
Path.home() / ".pouch" / "skills" / "agent-runtime" / "scripts" / "agent.py",
Path(__file__).resolve().parents[3] / "skills" / "agent-runtime" / "scripts" / "agent.py",
]
runtime_path = next((item for item in candidates if item.is_file()), None)
if runtime_path is None:
raise RuntimeError("找不到共享 agent-runtime: " + ", ".join(str(item) for item in candidates))
spec = importlib.util.spec_from_file_location("pouch_agent_runtime", runtime_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载共享 agent-runtime: {runtime_path}")
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Validate a tester CSV/YAML pair and summarize statuses from CSV only."""
from __future__ import annotations
import argparse
import csv
import sys
from collections import Counter
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError: # pragma: no cover
print("需要 PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
raise SystemExit(2)
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
RESULT_SET = frozenset(RESULTS)
class SummaryError(ValueError):
"""Raised when the CSV/YAML pair cannot be safely summarized."""
def _format_ids(ids: set[str]) -> str:
return ", ".join(sorted(ids)) or "(none)"
def _duplicate_ids(ids: list[str]) -> list[str]:
counts = Counter(ids)
return sorted(case_id for case_id, count in counts.items() if count > 1)
def load_csv(path: Path) -> list[dict[str, str]]:
"""Load CSV rows and reject malformed IDs/results with diagnostics."""
try:
handle = path.open(encoding="utf-8-sig", newline="")
except OSError as exc:
raise SummaryError(f"无法读取 CSV {path}: {exc}") from exc
with handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise SummaryError(f"CSV {path} 缺少表头")
missing_fields = [field for field in ("id", "result") if field not in reader.fieldnames]
if missing_fields:
raise SummaryError(f"CSV {path} 缺少必需列: {', '.join(missing_fields)}")
rows = list(reader)
ids: list[str] = []
errors: list[str] = []
for line_number, row in enumerate(rows, start=2):
case_id = (row.get("id") or "").strip()
result = (row.get("result") or "").strip()
ids.append(case_id)
if not case_id:
errors.append(f"CSV 第 {line_number} 行 testcase id 缺失")
if not result:
errors.append(f"CSV 第 {line_number} 行 result 缺失 (testcase id={case_id or '(missing)'})")
elif result not in RESULT_SET:
errors.append(
f"CSV 第 {line_number} 行 result 未知: {result!r} "
f"(testcase id={case_id or '(missing)'}; 允许值={', '.join(RESULTS)})"
)
duplicates = _duplicate_ids([case_id for case_id in ids if case_id])
if duplicates:
errors.append(f"CSV testcase id 重复: {_format_ids(set(duplicates))}")
if errors:
raise SummaryError("CSV 校验失败:\n- " + "\n- ".join(errors))
return rows
def load_yaml_results(path: Path) -> list[dict[str, Any]]:
"""Load only YAML results structure needed for count/ID validation."""
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except OSError as exc:
raise SummaryError(f"无法读取 YAML {path}: {exc}") from exc
except yaml.YAMLError as exc:
raise SummaryError(f"YAML 解析失败 {path}: {exc}") from exc
if not isinstance(data, dict) or not isinstance(data.get("results"), list):
raise SummaryError(f"YAML {path} 缺少 results 列表")
results = data["results"]
ids: list[str] = []
errors: list[str] = []
for index, item in enumerate(results, start=1):
if not isinstance(item, dict):
errors.append(f"YAML results[{index}] 不是 mapping")
continue
case_id = str(item.get("id") or "").strip()
ids.append(case_id)
if not case_id:
errors.append(f"YAML results[{index}] testcase id 缺失")
duplicates = _duplicate_ids([case_id for case_id in ids if case_id])
if duplicates:
errors.append(f"YAML testcase id 重复: {_format_ids(set(duplicates))}")
if errors:
raise SummaryError("YAML 校验失败:\n- " + "\n- ".join(errors))
issues = data.get("defects", data.get("test_issues"))
if issues is not None:
if not isinstance(issues, list):
raise SummaryError("YAML defects/test_issues 必须是列表")
result_ids = set(ids)
issue_errors: list[str] = []
for index, issue in enumerate(issues, start=1):
if not isinstance(issue, dict):
issue_errors.append(f"YAML defects[{index}] 不是 mapping")
continue
testcase = str(issue.get("testcase") or "")
issue_type = str(issue.get("issue_type") or "")
if not testcase or testcase not in result_ids:
issue_errors.append(f"YAML defects[{index}] testcase 无效: {testcase or '(missing)'}")
if issue_type not in {"product", "test"}:
issue_errors.append(f"YAML defects[{index}] issue_type 无效: {issue_type or '(missing)'}")
for field in ("expected", "observed", "artifact_hash", "environment", "evidence_path", "reproduction", "next_step"):
if field not in issue:
issue_errors.append(f"YAML defects[{index}] 缺少 {field}")
if issue_errors:
raise SummaryError("YAML defects 校验失败:\n- " + "\n- ".join(issue_errors))
return results
def summarize(csv_path: Path, yaml_path: Path) -> tuple[Counter[str], dict[str, int]]:
"""Validate the pair and return counts calculated from CSV result values."""
rows = load_csv(csv_path)
yaml_results = load_yaml_results(yaml_path)
csv_ids = [(row.get("id") or "").strip() for row in rows]
yaml_ids = [str(item.get("id") or "").strip() for item in yaml_results]
csv_id_set = set(csv_ids)
yaml_id_set = set(yaml_ids)
diagnostics: list[str] = []
if len(rows) != len(csv_id_set):
diagnostics.append(
f"CSV 数据行总数与唯一 testcase id 数不一致: rows={len(rows)} unique_ids={len(csv_id_set)}"
)
if len(rows) != len(yaml_results):
diagnostics.append(
f"CSV 数据行总数与 YAML results 数量不一致: csv_rows={len(rows)} yaml_results={len(yaml_results)}"
)
if csv_id_set != yaml_id_set:
diagnostics.append(
"CSV/YAML testcase id 集合不一致: "
f"仅 CSV 有 [{_format_ids(csv_id_set - yaml_id_set)}]; "
f"仅 YAML 有 [{_format_ids(yaml_id_set - csv_id_set)}]"
)
if diagnostics:
raise SummaryError("CSV/YAML 汇总校验失败:\n- " + "\n- ".join(diagnostics))
counts: Counter[str] = Counter((row.get("result") or "").strip() for row in rows)
total = sum(counts[name] for name in RESULTS)
if total != len(rows):
raise SummaryError(
"CSV 状态计数和不等于数据行总数: "
f"count_sum={total} csv_rows={len(rows)}"
)
metadata = {
"csv_rows": len(rows),
"csv_unique_ids": len(csv_id_set),
"yaml_results": len(yaml_results),
"count_sum": total,
}
return counts, metadata
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="校验 tester CSV/YAML 并从 CSV result 汇总状态")
parser.add_argument("--csv", required=True, type=Path, help="运行成绩单 CSV")
parser.add_argument("--yaml", required=True, type=Path, help="对应运行快照 YAML")
args = parser.parse_args(argv)
try:
counts, metadata = summarize(args.csv, args.yaml)
except SummaryError as exc:
print(f"汇总失败: {exc}", file=sys.stderr)
return 1
bits = " ".join(f"{name}={counts[name]}" for name in RESULTS)
print(f"summary source=CSV.result {bits} total={metadata['count_sum']}")
print(
"validated "
f"csv_rows={metadata['csv_rows']} "
f"csv_unique_ids={metadata['csv_unique_ids']} "
f"yaml_results={metadata['yaml_results']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Shared execution policy for tester's integration-first runs.
This module intentionally has no browser, mobile, deployment, or API driver.
Those adapters belong to the caller. It only controls the phase gate and the
stable five-state testcase result propagation so a caller cannot create a
journey session before preflight and integration have passed.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable, Mapping
from typing import Any
from failure_feedback import collect_issues
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
PHASES = ("preflight", "integration", "journey")
def classify_integration(*, ok: bool, service_reachable: bool = True) -> str:
"""Separate a contract assertion failure from an unavailable service."""
if not service_reachable:
return "environment-fail"
return "pass" if ok else "fail"
def _case_id(case: Mapping[str, Any] | str) -> str:
if isinstance(case, Mapping):
return str(case.get("id") or "")
return str(case)
def _not_run(
cases: Iterable[Mapping[str, Any] | str],
reason: str,
*,
blocked_by: Iterable[str] = (),
) -> list[dict[str, Any]]:
rows: list[dict[str, str]] = []
blockers = [str(item) for item in blocked_by if str(item)]
for case in cases:
row: dict[str, Any] = {"id": _case_id(case), "result": "not-run", "notes": reason}
if blockers:
row["blocked_by"] = blockers
if isinstance(case, Mapping):
row["test_type"] = str(case.get("test_type") or "")
row["suite"] = str(
case.get("client_suite")
or case.get("integration_suite")
or case.get("suite")
or ""
)
rows.append(row)
return rows
def _dependencies(
case: Mapping[str, Any] | str,
*,
default: Iterable[str] = (),
) -> list[str]:
if not isinstance(case, Mapping):
return [str(item) for item in default]
declared = case.get("depends_on", case.get("dependencies"))
if declared is None:
return [str(item) for item in default]
if isinstance(declared, str):
return [declared]
if isinstance(declared, Iterable):
return [str(item) for item in declared if str(item)]
raise ValueError(f"depends_on 必须是字符串或列表: {_case_id(case)}")
def _failed_dependencies(dependencies: Iterable[str], results: Mapping[str, Mapping[str, Any]]) -> list[str]:
return [
dependency
for dependency in dependencies
if str((results.get(dependency) or {}).get("result"))
in {"fail", "environment-fail", "not-run"}
]
def _issue_structures(results: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
issues = collect_issues(results)
return {"defects": issues, "test_issues": [dict(issue) for issue in issues]}
def run_integration_first(
*,
preflight_status: str,
integration_cases: Iterable[Mapping[str, Any] | str],
journey_cases: Iterable[Mapping[str, Any] | str],
execute_integration: Callable[[Mapping[str, Any] | str], str | Mapping[str, Any]],
create_journey_session: Callable[[], Any],
execute_journey: Callable[[Any, Mapping[str, Any] | str], str | Mapping[str, Any]],
) -> dict[str, Any]:
"""Execute integration cases before creating any journey session.
``preflight_status`` must be the status emitted by test-preflight. A
blocked/environment-fail gate records only ``not-run`` testcase rows and
never invokes either adapter. An integration failure stops the phase and
propagates ``not-run`` to all journey cases.
"""
integration = list(integration_cases)
journey = list(journey_cases)
if preflight_status != "ready":
reason = f"test-preflight={preflight_status},未创建客户端 session"
result_rows = _not_run(integration, reason, blocked_by=("test-preflight",)) + _not_run(journey, reason, blocked_by=("test-preflight",))
return {
"status": preflight_status,
"phase": "preflight",
"session_created": False,
"results": result_rows,
**_issue_structures(result_rows),
}
results: list[dict[str, Any]] = []
results_by_id: dict[str, dict[str, Any]] = {}
for case in integration:
case_id = _case_id(case)
dependencies = _dependencies(case)
blocked_by = _failed_dependencies(dependencies, results_by_id)
if blocked_by:
rows = _not_run(
[case],
"依赖的 integration 失败,未执行",
blocked_by=blocked_by,
)
results.extend(rows)
results_by_id[case_id] = rows[0]
continue
outcome = execute_integration(case)
row = dict(outcome) if isinstance(outcome, Mapping) else {"result": outcome}
row["id"] = _case_id(case)
if isinstance(case, Mapping):
row.setdefault("test_type", str(case.get("test_type") or "integration"))
row.setdefault("suite", str(case.get("integration_suite") or case.get("suite") or ""))
result = str(row.get("result") or "not-run")
if result not in RESULTS:
raise ValueError(f"未知 integration 结果: {row['id']}={result}")
row["result"] = result
results.append(row)
results_by_id[case_id] = row
integration_ids = [_case_id(case) for case in integration]
has_runnable_journey = any(
not _failed_dependencies(_dependencies(case, default=integration_ids), results_by_id)
for case in journey
)
session = create_journey_session() if has_runnable_journey else None
for case in journey:
dependencies = _dependencies(case, default=integration_ids)
blocked_by = _failed_dependencies(dependencies, results_by_id)
if blocked_by:
row = _not_run(
[case],
"依赖的用例失败,journey 未执行",
blocked_by=blocked_by,
)[0]
results.append(row)
results_by_id[_case_id(case)] = row
continue
outcome = execute_journey(session, case)
row = dict(outcome) if isinstance(outcome, Mapping) else {"result": outcome}
row["id"] = _case_id(case)
if isinstance(case, Mapping):
row.setdefault("test_type", str(case.get("test_type") or "journey"))
row.setdefault("suite", str(case.get("client_suite") or case.get("suite") or ""))
result = str(row.get("result") or "not-run")
if result not in RESULTS:
raise ValueError(f"未知 journey 结果: {row['id']}={result}")
row["result"] = result
results.append(row)
results_by_id[_case_id(case)] = row
status = "fail" if any(row["result"] == "fail" for row in results) else (
"environment-fail" if any(row["result"] == "environment-fail" for row in results) else "pass"
)
return {
"status": status,
"phase": "journey" if journey else "integration",
"session_created": has_runnable_journey,
"results": results,
**_issue_structures(results),
}
+18
View File
@@ -22,6 +22,24 @@ risks:
- mutating
- destructive
default_max_risk: readonly
# standard enforces the default_max_risk/authorize policy. Writes-style isolated
# resettable environments may set this to isolated-full; risk values remain on
# cases and are still reported.
execution_profile: standard
client_suites:
client-smoke:
default: true
client-regression:
default: false
safety:
default: false
integration_suites:
api-contract:
default: false
authorization:
default: false
build-contract:
default: false
vars: {}
authorize:
mutating: AUTHORIZE_MUTATING
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec python3 "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/scripts/mobile_bundle.py" "$@"

Some files were not shown because too many files have changed in this diff Show More