chore(repo): initialize team collaboration repository
CI / Python 3.12 (push) Waiting to run
CI / Python 3.9 (push) Waiting to run

This commit is contained in:
2026-07-27 20:40:12 +08:00
commit c91a64fddb
109 changed files with 21121 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
# Adapter SDK:产品扩张的关键边界
方案 §6.4 的判断是对的——**适配器是产品扩张的关键**。每接一个客户,
新增的工作量应该收敛到"写一个适配器",而不是改引擎。这一波把这条边界钉死。
实现:`services/host-agent/flashops_agent/adapters/`
## 契约(7 个方法,一个都不能少)
```python
class ToolAdapter(Protocol):
name: str
version: str
def discover(self, ctx: AdapterContext) -> Capabilities: ...
def precheck(self, ctx: AdapterContext) -> CheckResult: ...
def execute(self, step: StepSpec, ctx: AdapterContext) -> ExecutionHandle: ...
def collect(self, handle: ExecutionHandle) -> EvidenceArtifact: ...
def cancel(self, handle: ExecutionHandle) -> None: ...
def health(self) -> HealthStatus: ...
def normalize_result(self, raw: RawResult) -> UnifiedResult: ...
```
| 方法 | 职责 | 什么时候被调 |
|---|---|---|
| `discover` | 报告本工具在这台主机上能干什么(版本、支持的设备、能力位) | Agent 启动 / 资产刷新 |
| `precheck` | 执行前自检:工具在不在、权限够不够、设备在不在、参数合不合法 | 每个步骤执行前,**失败即拒绝执行** |
| `execute` | 启动执行,**立即返回句柄,不阻塞** | 步骤开始 |
| `collect` | 从句柄收集产物:stdout/stderr、结果文件、设备日志 | 步骤结束 / 中途取证 |
| `cancel` | 终止执行(含进程树),保证不留孤儿进程 | 紧急停止 / 超时 / 恢复前 |
| `health` | 适配器自身健康(工具还在吗、许可证过期没) | 心跳周期 |
| `normalize_result` | 把工具原生输出翻译成统一结果模型 | `collect` 之后 |
## `normalize_result` 是最重要的一个
它决定了失败签名能不能跨工具聚类。原生输出千奇百怪,统一模型只有一种:
```python
@dataclass
class UnifiedResult:
outcome: Literal["PASS", "FAIL", "ERROR", "TIMEOUT"]
error_codes: List[str] # 归一化后的错误码,如 ["NVME_STATUS_0x2002"]
log_templates: List[str] # 日志模板化后的指纹,数字/路径已替换为占位符
metrics: Dict[str, float] # iops / latency_p99 / temperature_c ...
device_state: DeviceState # 枚举状态、固件版本、SMART 关键项
integrity: IntegrityState # OK / MISMATCH / NOT_CHECKED
artifacts: List[str] # 产物 URI
```
**适配器的实现者只需要保证一件事**:同样的故障,`error_codes`
`log_templates` 要稳定。不稳定 → 签名散开 → 聚类失效 → 同一个问题重复提单,
客户第一时间就会发现这个系统在制造噪音。
日志模板化规则在 `flashops_agent/adapters/templating.py`
数字 → `<N>`,十六进制 → `<HEX>`,路径 → `<PATH>`UUID → `<UUID>`
时间戳 → `<TS>`。所以
`"nvme0n1: I/O error, sector 12345678 at 2026-07-27T03:14:15"`
归一为 `"nvme<N>n<N>: I/O error, sector <N> at <TS>"`
## 这一波带的适配器
| 适配器 | 状态 | 说明 |
|---|---|---|
| `ShellAdapter` | ✅ 真实 | 执行签名命令模板,进程树管理、超时、输出捕获 |
| `NvmeCliAdapter` | ⚠️ 真实骨架 + 模拟回退 | 命令与解析是真的;无真实设备时走模拟器 |
| `FioAdapter` | ⚠️ 真实骨架 + 模拟回退 | 解析 fio `--output-format=json` |
| `SimulatedDutAdapter` | ✅ 模拟 | 可注入故障的假 DUT,让全链路无硬件可跑 |
| `VendorFlashAdapter` | ⬜ 占位 | 客户私有刷写工具——**这个必须等拿到真实 SOP 再写** |
`VendorFlashAdapter` 故意留空并在导入时抛 `NotImplementedError`
猜客户的刷写工具长什么样是纯浪费——报告 Phase 0 的结论是先拿 SOP 再写代码。
## 写一个新适配器的清单
1. 继承 `BaseAdapter`,实现 7 个方法
2.`adapters/__init__.py``REGISTRY` 里注册(key 就是工作流 YAML 里的 `adapter:`
3. 危险命令**必须**在控制平面 `safety/templates.py` 注册签名模板;
适配器里不允许拼接任意命令行——参数只能来自模板 schema 校验过的字典
4. 写一个 `tests/adapters/test_<name>.py`:至少覆盖
`precheck` 失败路径、超时路径、`normalize_result` 的两个不同故障输出
5.`make test` 里的适配器契约测试(`test_adapter_contract.py` 会对
`REGISTRY` 里每个适配器自动断言 7 个方法齐全且签名正确)
## 边界纪律
- 适配器**不知道** Run、Workflow、状态机的存在。它只认 `StepSpec``AdapterContext`
- 适配器**不做**重试和恢复决策。挂了就如实报告,重试与恢复是控制平面的事。
- 适配器**不写**数据库,产物写本地临时目录,由 Agent 上传。
- 适配器里**不允许**出现 `if customer == "X"` 这种分支。客户差异靠不同适配器 +
工作流参数表达,不靠代码里的 if。
+90
View File
@@ -0,0 +1,90 @@
# 控制平面 ↔ Host Agent 协议
> **当前状态(2026-07-27**:独立 Host Agent 已实现注册、注册口令、bearer token、
> 本地 `0600` 凭据文件、主机指纹和心跳;控制平面只保存 token SHA-256 摘要。
> 心跳迟到 15 秒会把 Host 降级为 `DEGRADED`,丢失 30 秒会标记为 `OFFLINE`
> 安全预检会先刷新该状态,避免失联 Host 被误判为可用。
> Step 租约、事件、完成与产物端点仍是下一阶段契约。`make demo` 暂时继续使用
> 控制平面内置模拟执行器验证状态、恢复与证据链。
Agent 是**拉模式**。控制平面永远不主动连接测试主机。
理由:客户测试主机在内网/NAT 后面,很多实验室根本不给外部反连;
拉模式还顺带解决了 Agent 重启后的重新接入问题。
## 端点
| 状态 | 方法 | 路径 | 用途 |
|---|---|---|---|
| 已实现 | POST | `/api/v1/agent/register` | 首次注册 / token 轮换;上报主机指纹与工具能力 |
| 已实现 | POST | `/api/v1/agent/{agent_id}/heartbeat` | bearer 心跳,上报健康和设备探针;返回待办指令 |
| 已实现 | GET | `/api/v1/agent/hosts` | 查看 Host 与 Agent 在线投影,不返回 token |
| 待实现 | POST | `/api/v1/agent/{agent_id}/lease` | 领取下一个步骤(长轮询,最多挂 20s) |
| 待实现 | POST | `/api/v1/agent/{agent_id}/steps/{step_id}/events` | 批量上报执行事件与日志片段 |
| 待实现 | POST | `/api/v1/agent/{agent_id}/steps/{step_id}/complete` | 上报步骤终态 + UnifiedResult + 产物清单 |
| 待实现 | POST | `/api/v1/agent/{agent_id}/artifacts` | 上传产物(日志 / 结果文件 / 画面) |
带外控制器走独立端点(它必须能在主机死后独立上报):
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | `/api/v1/oob/{controller_id}/heartbeat` | 带外心跳 + 电源状态 + 温度 + 画面指纹 |
| POST | `/api/v1/oob/{controller_id}/observation` | 主动上报观测(蓝屏画面、掉电、按钮触发) |
## 心跳返回的指令
心跳响应已经带 `commands` 字段;当前固定为空数组。命令队列接入后,Agent 收到后立即执行:
```json
{
"ok": true,
"server_time": "2026-07-27T03:14:15Z",
"commands": [
{"kind": "CANCEL_STEP", "step_id": "..."},
{"kind": "SOFT_RESTART", "reason": "recovery_L1"},
{"kind": "EMERGENCY_STOP", "run_id": "..."},
{"kind": "REFRESH_ASSETS"}
]
}
```
紧急停止走心跳而不是新连接——心跳是唯一能保证到达的通道。
最坏情况延迟 = 心跳间隔(5s)。真正需要毫秒级的停止靠带外物理急停,不靠软件。
## 租约(lease)语义
```
Agent ──lease──▶ 控制平面
├─ 有活:分配步骤,step → DISPATCHED,写租约(TTL 60s
└─ 没活:长轮询挂起最多 20s,返回 204
Agent ──ack───▶ step → RUNNING
(租约到期未 ack 或未 complete)→ step 回 PENDING,重新派发
```
租约 TTL 必须**大于**步骤超时?不——恰恰相反:租约由 Agent 在执行期间通过心跳续期。
Agent 死了 → 心跳停 → 租约到期 → 步骤回收。这是 Agent 崩溃能被发现的机制之一
(另一条是心跳超时判定,见 state-machine.md §5)。
## 认证
首次注册带 `X-FlashOps-Enrollment-Token`。开发态未配置注册口令时允许本机演示;
生产态未配置时注册接口返回 503。注册成功下发 bearer token,之后每个请求带
`Authorization: Bearer <token>``agent_id``test_host_id` 绑定,重新注册会轮换 token。
原始 token 仅存于 Agent 本机 `0600` state 文件;控制平面数据库只保存 SHA-256 摘要。
Agent 客户端保留可选 gateway Basic Auth 能力,供未来重新启用边界认证或部署在其他网关后使用;
当前 `flashops.imagebrewing.com` 预览环境未启用这层认证。
**下一波必须升级**:方案 §6.2 要求"每个 Agent 使用唯一证书或密钥"。
bearer token 在客户内网够用,但要进客户安全评审得上 mTLS。
升级点在 `api/deps.py::require_agent()` 一处。
## 幂等(待实现)
Step 租约接入时,所有 Agent → 控制平面的 Step 写请求必须带
`Idempotency-Key`(Agent 生成的 UUID)。控制平面需要记录并拒绝重复副作用;
`run_events` 已有唯一约束 `(run_id, idempotency_key)`,但当前注册 / 心跳链路尚未实现
通用的请求幂等存储。
这一条在真实实验室里不是可选项:主机重启、网线松动、Wi-Fi 掉线是常态,
没有幂等就会出现"一次故障记了三条"的时间线,取证时说不清。
+99
View File
@@ -0,0 +1,99 @@
# 架构:五层 → 代码目录的逐层映射
> **实现状态(2026-07-27)**:本文描述目标架构。当前已落地控制平面的模型、
> 安全门禁、事件记录、Run 转移表、恢复决策、最小证据包、内置模拟执行器,以及
> 独立 Host Agent 的注册、token、心跳和 Host 状态老化链路;Step 租约、Adapter
> 执行、消息总线替换和生产对象存储仍未落地。
方案 §5.1 定义了五层架构。这里给出每一层**落到哪个目录、边界在哪、谁不能调用谁**。
```
┌─────────────────────────────────────────────────────────────┐
│ 交互与集成层 apps/console (Next.js) · CLI · Open API │
├─────────────────────────────────────────────────────────────┤
│ 控制平面 services/control-plane/flashops_control/ │
│ services/ 编排 safety/ 门禁 api/ 接口 │
├─────────────────────────────────────────────────────────────┤
│ 执行平面 services/host-agent/flashops_agent/ │
│ adapters/ 工具适配器 │
├─────────────────────────────────────────────────────────────┤
│ 带外平面 services/host-agent/flashops_agent/oob/ │
│ (独立进程/独立网络,模拟实现在 simulator/) │
├─────────────────────────────────────────────────────────────┤
│ 证据与智能层 control-plane/.../evidence/ · signatures/ │
│ events/ 事件总线 │
└─────────────────────────────────────────────────────────────┘
```
## 依赖方向(单向,不可逆)
```
console ──HTTP/SSE──▶ control-plane ◀──HTTP──── host-agent
│ │
│ ├─▶ adapters ─▶ 客户工具
▼ │
数据库 └─▶ oob ─▶ 带外控制器
```
**硬规则:**
- `host-agent` **不得**导入 `control-plane` 的任何模块。两者只通过 `docs/agent-protocol.md`
定义的 HTTP 契约通信。理由:Agent 要能单文件分发到客户 Windows 机器,
控制平面代码不进客户测试主机。
- `control-plane` **不得**主动连接 Agent(Agent 是拉模式)。理由:客户测试主机在
内网/NAT 后面,反向连接在真实实验室里活不下来。
- `engine/` **不得**直接读写数据库。它只操作 `Snapshot` 值对象,返回 `Effect` 列表。
持久化由 `runtime.py` 施加。理由:状态机要能不起数据库单测。
- `api/` **不得**包含业务判断。路由只做校验 + 调用 `engine`/`services`
- 任何危险动作(刷写、Format、Sanitize、断电)**必须**先过 `safety/`
`safety/` 的拒绝发生在下发给 Agent 之前。
## 控制平面内部分层
```
flashops_control/
├─ config.py 配置(产品名、DSN、路径、超时策略)
├─ db.py 引擎/会话/建表
├─ models/ SQLAlchemy 表定义(贫血模型,只管持久化)
├─ schemas.py Pydantic 出入参(当前最小 API 契约)
├─ events/
│ ├─ bus.py 进程内事件总线(生产换 NATS/Redis,接口不变)
│ └─ recorder.py 事件溯源写入:唯一允许改 Run 状态的入口
├─ engine/
│ ├─ states.py Run/Step 状态枚举 + 转移表(纯数据)
│ ├─ recovery.py 五级恢复阶梯策略(纯函数)
│ └─ (目标)planner/runtime:独立 Agent 接入后补齐
├─ safety/
│ ├─ gates.py DUT 绑定 / 系统盘保护 / 破坏性白名单
│ └─ templates.py 签名命令模板注册表 + 参数 schema 校验
├─ signatures/ (目标)失败签名(8 要素哈希)与聚类
├─ evidence/ 最小 Evidence Bundlemanifest + 事件流)
├─ services/runs.py 当前内置模拟编排(后续拆成 Agent + runtime
├─ seed.py 可重复执行的模拟资产与工作流
└─ api/ FastAPI 路由
```
**`engine/` 的纯度是刻意的。** 当前 `states.py` / `recovery.py`
不导入 SQLAlchemy、不导入 FastAPI、不做 I/O、不看时钟
(时间从 `Snapshot.now` 传入)。这让状态机的全部分支都能用普通 pytest 覆盖,
不需要起库、不需要 sleep。后面几波会不断往状态机里加分支(新故障类型、
新恢复策略、多工位调度),这条边界是唯一能防止它烂掉的东西。
## 开发形态 vs 生产形态
| 组件 | 开发(`make dev` | 生产(`docker-compose` | 换的时候动哪 |
|---|---|---|---|
| 数据库 | SQLite (aiosqlite) | PostgreSQL 16 | 只改 `DATABASE_URL` |
| 队列/总线 | 进程内 asyncio | Redis / NATS | `events/bus.py` 换实现 |
| 对象存储 | `var/objects/` 本地目录 | MinIO | `storage.py` 换实现 |
| Agent | 内置模拟执行器 + 独立 Agent 心跳 | 客户主机上的 systemd/Windows 服务 | 补齐 Step lease/runtime |
| 带外 | 模拟控制器 | 树莓派 / JetKVM / 智能 PDU | `oob/` 换实现 |
三处"换实现"都在接口后面,且开发态实现本身就是测试替身——不存在"生产才发现跑不通"。
## 为什么不用 Temporal / Airflow
见 [ADR-0003](decisions/0003-own-state-machine-over-temporal.md)。一句话:
恢复语义(带外断电、冻结现场、误盘防护)是本产品的差异化本身,
不能交给通用工作流引擎的重试模型。等到多工位跨节点调度成为瓶颈时再评估 Temporal,
`runtime.py` 是唯一需要替换的文件。
@@ -0,0 +1,39 @@
# ADR-0001Monorepo 与技术栈选型
- 日期:2026-07-27
- 状态:已接受
## 背景
第一波要同时出前端控制台、控制平面、Host Agent 三个可交付物,团队规模是 1-2 人。
## 决定
单仓库(monorepo),三个可独立部署的单元:`apps/console``services/control-plane`
`services/host-agent`。技术栈按方案 §6.1Next.js + FastAPI + PostgreSQL。
**开发态零外部依赖**SQLite 替 Postgres、进程内 asyncio 替 Redis、
本地目录替 MinIO。三者都在接口后面,`docker-compose.yml` 给出生产形态。
## 理由
- 1-2 人团队用多仓库,跨仓改一个接口要开三个 PR,纯损耗。
- 控制平面与 Agent 的 HTTP 契约会在接第一个客户时频繁改。同仓库能一次改完、
一次跑通端到端测试。
- 开发态零依赖是为了**降低启动摩擦**:这台机器上没有 Docker,客户现场的
Windows 测试主机上也大概率装不了 Docker。能 `python -m` 直接跑起来的东西,
在实验室里活得久。
- Python 3.9 兼容:客户测试主机的 Python 版本不可控,Agent 必须往低了兼容。
所以全仓库用 `Optional[X]` 而不是 `X | None`,用 `List[X]` 而不是 `list[X]`
## 代价
- SQLite 与 Postgres 有行为差异(并发写、JSON 查询、事务隔离)。
缓解:ORM 层不用任何方言特有类型;CI 后续要在 Postgres 上再跑一遍测试。
- monorepo 在团队超过 5 人后会需要更强的 CI 分区。届时再拆。
## 什么时候推翻
- 团队 >5 人且前后端分离开发节奏明显不同步 → 拆仓。
- Agent 需要单文件分发到无 Python 环境的 Windows → 把 Agent 用 Go 重写
(方案 §6.1 已经把这条写成演进建议)。届时 HTTP 契约不变,只换实现语言。
@@ -0,0 +1,44 @@
# ADR-0002:以事件溯源作为 Run 的唯一真相
- 日期:2026-07-27
- 状态:已接受
## 背景
产品要交付的三件事——**统一时间线**、**审计**、**自动复现**——都需要回答同一个问题:
"当时到底按什么顺序发生了什么"。
同时,观测是**双通道**的:Agent 报一套,带外控制器报一套,两者时钟不同步,
主机断电时 Agent 那一路会直接消失。
## 决定
`run_events` 是 append-only 的唯一真相。`runs` / `run_steps` / `recovery_actions`
是可以从事件重放重建的**读模型**。
序号 `seq` 由控制平面单点分配(不是时间戳排序),Agent 事件与带外事件进同一条序列。
仓储层**不提供** `update_run_state()` 类 API。改状态的唯一入口是
`events/recorder.py::record()`,它写事件并同步更新投影。
## 理由
- 主机断电时,Agent 的最后几条日志可能永远到不了。带外控制器的观测
(掉电时刻、画面指纹)是那段时间唯一的证据。两路事件必须在同一条时间线上
才能交叉校验——"Agent 最后一条日志 seq=812,带外报掉电 seq=813"这种判断
只有单点序号能给。
- 审计要求记录"操作者、审批者、命令模板版本、输入参数、结果、撤销动作"
(方案 §6.6)。这些天然是事件,硬塞进当前状态表会做成一堆冗余字段。
- 自动复现(§8.4)需要"失败前后的最小步骤区间"。有完整事件流才能裁剪。
## 代价
- 写路径变长,每次状态变化多一次插入。
- `run_events` 会很大:100 循环 × 每轮 ~30 事件 = 3000 条/Run。
缓解:事件按 run_id 分区友好,日志正文不进事件表(只存对象存储 URI)。
- 开发者容易忍不住直接 UPDATE。缓解:仓储层不给这个 APIcode review 卡这一条。
## 什么时候推翻
不推翻。真要优化,是加快照(每 N 个事件存一次状态快照加速重放),
不是放弃事件溯源。
@@ -0,0 +1,51 @@
# ADR-0003:自研状态机,不用 Temporal / Airflow
- 日期:2026-07-27
- 状态:已接受(方案 §6.1 把 Temporal 列为演进建议,这里给出不在首版用的理由)
## 背景
工作流编排有成熟方案:Temporal、Airflow、Prefect、OpenTAP。
自研状态机通常是坏味道。
## 决定
首版自研:`engine/states.py` 转移表 + `engine/state_machine.py` 纯函数
+ `engine/runtime.py` 异步循环。
## 理由
产品的差异化恰好落在通用引擎**不管**的那一层:
1. **恢复语义不是重试。** Temporal 的重试模型是"再调一次这个 activity"。
我们需要的是"主机蓝屏了 → 带外触发主板 Reset → 等主机起来 → 验证 DUT
重新枚举 → 从检查点续跑"。这是跨越进程、跨越主机生死的物理恢复,
不是函数重试。硬套通用引擎会变成在 activity 里塞一堆状态判断,比自研更难维护。
2. **非幂等步骤默认不重跑。** 通用引擎的默认值是"重试是安全的"。
我们这里最贵的一条规则是"刷写步骤挂了**不要**自动重跑"。
跟框架默认值对着干,不如自己控制。
3. **冻结现场是一等状态。** 数据完整性失败要立刻停止一切覆盖性动作并保留现场。
通用引擎里这是"失败",我们这里它是一个需要保持很久、等人来看的活状态。
4. **可测试性。** 纯函数状态机能把"30 秒心跳超时后带外在线则走 L3"这种规则
在毫秒内测完。挂在 Temporal 上要起测试环境。后面几波会疯狂往状态机加分支,
测试成本是决定性因素。
5. 依赖成本:Temporal 需要自己的数据库和服务集群。客户是**内网私有化部署**,
多一个必须运维的组件就多一个交付摩擦。
## 代价
- 跨节点调度、持久化定时器、版本化工作流这些 Temporal 白送的能力要自己做。
这一波不需要(单工位),Phase 3 多工位时会痛。
- 自研状态机容易随时间腐化成 if 堆。缓解:转移表是纯数据、`decide()` 是纯函数、
每加一个分支必须配单测,三条规矩写进了 `docs/state-machine.md §6`
## 什么时候推翻
多工位跨节点调度成为瓶颈时(Phase 3,预计 20+ 工位)。
届时替换范围仅限 `runtime.py`——`states.py` / `state_machine.py` / `recovery.py`
是纯逻辑,可以原样搬进 Temporal 的 workflow 定义里。
这也是把它们做成纯函数的第二个理由。
@@ -0,0 +1,51 @@
# ADR-0004:安全门禁放在控制平面,Agent 保持"笨"
- 日期:2026-07-27
- 状态:已接受
## 背景
危险动作有三类:刷固件、Format/Sanitize、断电。任何一次误盘 = PoC 判定失败
(方案 §9.3:0 次错盘、0 次系统盘破坏)。
门禁可以放在 Agent(离设备近,判断准)或控制平面(离决策近,可审计)。
## 决定
**全部放控制平面**,且拒绝发生在**下发之前**。Agent 只执行已批准的命令模板 +
schema 校验过的参数,自己不做任何"这条命令安不安全"的判断。
Agent 侧保留的唯一防线是 `precheck`(设备在不在、路径对不对),
它是**执行前自检**,不是安全决策。
## 理由
- **可审计**:审批记录、模板版本、操作者、参数必须与决策在同一个地方,
否则审计链断在 Agent 上。客户安全评审第一个问的就是这个。
- **Agent 可信度低**:Agent 跑在客户的测试主机上,那台机器会蓝屏、会被人手动改配置、
会装乱七八糟的工具。安全判断不能依赖一个随时可能处于异常状态的环境。
- **升级路径**:安全规则改了,改控制平面就全网生效。要靠 Agent 判断,
就得推送 Agent 升级到每台客户主机——在实验室里这是以周计的事。
- **可测试**:门禁是纯函数(`safety/gates.py`),误盘场景可以在单测里穷举,
不需要真的接一块盘来试。
## 具体门禁(`safety/gates.py`
1. **DUT 绑定**:序列号 + 设备路径 + `allow_destructive` 标签三信号一致才放行。
任一不匹配 → 拒绝,记 `SAFETY_REJECTED` 事件。
2. **系统盘保护**:解析目标设备的挂载点/启动标志/分区表,命中系统盘立即拒绝。
这一条**没有** override 开关,代码里不给绕过的口子。
3. **命令模板白名单**:危险命令只能来自 `safety/templates.py` 注册表,
参数受 JSON Schema 约束。不接受自由文本命令行。
4. **环境指纹一致性**:A/B 对比的两次运行环境指纹不一致 → 结论标记为不可比。
## 代价
- 控制平面必须掌握足够的设备信息才能判断(依赖 Agent 上报的设备探针数据)。
上报延迟 → 判断基于稍旧的数据。缓解:危险步骤执行前强制刷新一次探针,
Agent 的 `precheck` 再做一次现场核对,两者不一致直接拒绝。
- 多一次往返。危险动作本来就不该快。
## 什么时候推翻
不推翻。可以增强(加人工审批门、加双人复核),但不下放到 Agent。
@@ -0,0 +1,43 @@
# ADR-0005:模拟器是一等公民,不是测试脚手架
- 日期:2026-07-27
- 状态:已接受
## 背景
第一波没有真实硬件(没有可牺牲的 DUT、没有带外控制器、没有温箱)。
常规做法是先写代码、等硬件到位再联调。
## 决定
把**模拟 DUT + 模拟带外控制器 + 故障注入器**做成产品代码的一部分
`services/host-agent/flashops_agent/simulator/`),与真实适配器共用同一套接口,
而不是塞进 `tests/` 当替身。
模拟器支持注入的故障至少覆盖报告 Phase 1a 要求验证的三类:
杀 Agent、蓝屏/panic、拔网线;外加掉盘与数据完整性失败。
## 理由
1. **它是长期资产,不是临时替身。** 有了真硬件之后,模拟器仍然是唯一能
在 CI 里跑"100 循环 + 注入 20 次故障"的东西。真设备跑一轮要几小时,
CI 里不可能天天跑。
2. **恢复逻辑的测试覆盖只能靠它。** 五级恢复阶梯里的 L4/L5(ATX 长按、AC 断电)
在真机上每测一次都有风险且很慢。这条路径恰恰是产品价值所在,必须能高频回归。
3. **它是谈判道具的底座。** 报告把 Phase 1a 的产出定义为"一台会自救的演示台——
同时是合伙人与客户谈判的最强道具"。在拿到硬件之前,模拟器版本已经能把
完整故事演一遍:注入蓝屏 → 看着它自己救回来 → 断点续跑 → 出证据包。
4. **它划清了接口。** 能被模拟器替换掉的地方,就是硬件接入的边界。
写模拟器的过程本身就在逼迫接口设计正确。
## 代价
- 模拟器与真实实现可能漂移(模拟器里能过、真机上过不了)。
缓解:`tests/test_adapter_contract.py` 对模拟与真实适配器跑**同一套契约测试**;
真实适配器接入后,契约测试是第一道闸。
- 有"在模拟器上跑通了就以为完事了"的风险。缓解:README 的能力表里,
模拟实现一律标 ⚠️ 或"模拟",不标 ✅ 真实。
## 什么时候推翻
不推翻。真实硬件接入后模拟器保留,作为 CI 的默认执行后端。
+96
View File
@@ -0,0 +1,96 @@
# 领域模型:7 个核心对象 → 14 张表
方案 §5.3 列了 7 个核心领域对象。当前 SQLAlchemy 元数据包含 14 张表。
额外表用于事件溯源、恢复动作、资源锁、失败实例与审计;它们不是新的顶层产品概念,
而是核心对象的行为记录和读模型。
## 对象 → 表
| 方案对象 | 表 | 补充说明 |
|---|---|---|
| DUT | `duts` | 序列号是防错盘的第一信号 |
| Test Host | `test_hosts` + `oob_controllers` | 带外控制器独立成表:它必须能在主机死后独活 |
| Firmware Artifact | `firmware_artifacts` | 哈希 + 签名 + 适用型号,缺一不可审计 |
| Workflow | `workflows` | 存 spec 原文 + spec_hash,改一个字就是新版本 |
| Run | `runs` + `run_steps` + `run_events` + `recovery_actions` | 见下 |
| Failure Signature | `failure_signatures` + `run_failures` | 签名是聚类锚点,失败实例挂在它下面 |
| Evidence Bundle | `evidence_bundles` | manifest 记录完整率,缺字段要能查出来 |
| —(新增) | `resource_locks` | DUT/主机独占,防并发踩踏 |
| —(新增) | `audit_log` | 谁在什么时候用哪个模板做了什么 |
## Run 为什么拆成四张表
`runs` 只存**当前投影**(状态、进度、结论)。真相在 `run_events`
```
run_events (append-only, 唯一真相)
│ projection
runs / run_steps / recovery_actions (可重建的读模型)
```
任何时刻都能靠重放 `run_events` 重建 `runs` 的状态——这是"事件溯源"落地的含义,
也是统一时间线、审计、自动复现三个功能的共同地基。
**代价**:写路径必须走 `events/recorder.py`。仓储层故意**没有**
`update_run_state()` 这种 API。想改状态?记一条事件,投影自己会跟上。
## 关键字段的设计理由
### `duts.allow_destructive` + `duts.serial`
破坏性动作(Format / Sanitize / 刷写)的三重校验:序列号匹配、
`allow_destructive=True`、设备路径不是系统盘。三个信号缺一个就拒绝执行。
误盘一次 = PoC 判定失败(方案 §9.3),所以这个字段不给 API 直接改,
只能走带审批记录的资产管理接口。
### `runs.env_fingerprint`JSON,不可变快照)
任务开始时冻结:主板 / BIOS / OS / 内核 / 驱动 / Agent 版本 / 工具版本 / DUT 固件。
A/B 对比的前提是环境相同——固件之外任何一项变了,对比结论就不成立。
签名哈希也吃这个指纹,所以"换了台机器复现不出来"能被自动识别为不同签名。
### `runs.unattended_completion` + `runs.human_touches`
这两个字段是**销售武器**,不是技术指标。UCR(无人值守完成率)和人工触碰次数
是客户签字确认 ROI 的凭据(方案 §9.3、报告 Phase 2 现场指标)。
从第一行代码就记,不要等到要卖了才补。
### `run_events.seq`(每个 run 内单调递增)
时间戳会因为主机断电、时钟漂移、带外控制器与主机时钟不同步而乱序。
`seq` 由控制平面单点分配,保证时间线可重放。带外事件与 Agent 事件
进同一条序列——双通道观测的交叉校验靠它。
### `failure_signatures.hash`8 要素)
```
hash(workflow_step, normalized_error_codes, log_templates, host_state,
dut_enumeration_state, data_integrity_state, recovery_outcome,
environment_fingerprint)
```
只用错误码会把"掉盘"和"脚本超时"归成一类,聚类就废了。
`log_templates` 是日志模板化后的结果(数字/路径/时间被替换为占位符),
不是原始日志——否则每条日志都是新签名。
### `run_failures.failure_class`
`DUT_DEFECT` / `INFRA_FAILURE` / `SCRIPT_FAILURE` / `DATA_INTEGRITY` / `UNKNOWN`
**`INFRA_FAILURE` 必须与 `DUT_DEFECT` 分开统计**(方案 §4.3)。
把网络断了、磁盘满了算成 SSD 缺陷,客户第一周就不信任这个系统了。
基础设施误报率 <5% 是 MVP 硬指标。
## 状态字段一览
| 表 | 字段 | 取值 |
|---|---|---|
| `runs` | `state` | QUEUED / PREFLIGHT / RUNNING / RECOVERING / PAUSED / FROZEN / COMPLETED / ABORTED / REJECTED |
| `runs` | `verdict` | PASS / FAIL / INCONCLUSIVE / null |
| `run_steps` | `state` | PENDING / DISPATCHED / RUNNING / SUCCEEDED / FAILED / TIMED_OUT / SKIPPED / CANCELLED |
| `test_hosts` | `status` | ONLINE / DEGRADED / OFFLINE / UNKNOWN |
| `duts` | `status` | IDLE / IN_USE / QUARANTINED / MISSING |
| `recovery_actions` | `outcome` | RECOVERED / FAILED / ESCALATED / FROZEN |
详见 [state-machine.md](state-machine.md)。
+134
View File
@@ -0,0 +1,134 @@
# 状态机、恢复阶梯与检查点语义
这是整个产品的核心。差异化不在"能跑命令",在**跑挂了之后会发生什么**。
当前实现:`engine/states.py`Run 转移表)、`engine/recovery.py`(恢复纯函数)、
`events/recorder.py`(事件与投影写入口)以及 `services/runs.py`(内置模拟编排)。
Step 租约状态机与独立 runtime 属于下一阶段。
---
## 1. Run 状态机
```
┌──────────┐
│ QUEUED │ 等资源(DUT/主机独占锁)
└────┬─────┘
│ 锁到手
┌────▼─────┐
│ PREFLIGHT│ 安全门禁:序列号绑定、系统盘保护、
└──┬────┬──┘ 破坏性白名单、环境指纹冻结
门禁拒绝 │ │ 全过
┌────▼┐ │
│REJEC│ │
│ TED │ │
└─────┘ │
┌───────▼──────┐
┌─────────▶│ RUNNING │◀────────┐
│ └──┬───┬───┬───┘ │ 恢复成功,
│ 人工继续 │ │ │ │ 从检查点续跑
┌────┴───┐ │ │ │ ┌────┴──────┐
│ PAUSED │◀───────┘ │ └───────▶│ RECOVERING│
└────────┘ 人工暂停 │ 心跳超时/ └────┬──────┘
│ 蓝屏/掉盘 │ 阶梯耗尽
全部循环完成 │ 或数据完整性失败
┌─────▼─────┐ ┌────▼────┐
│ COMPLETED │ │ FROZEN │ 冻结现场,
│ PASS/FAIL │ └─────────┘ 停止一切覆盖性动作
└───────────┘
```
任何非终态 → `ABORTED`(紧急停止:Web / 物理按钮 / 带外均可触发)。
**终态**`COMPLETED` / `ABORTED` / `FROZEN` / `REJECTED`。终态不可再转移,
`assert_transition()` 会抛 `IllegalTransition`——这个异常在生产里意味着有代码
绕过了事件溯源,属于必须修的 bug,不是可以吞掉的告警。
转移表是 `states.py` 里的一份纯数据 `RUN_TRANSITIONS: Dict[RunState, FrozenSet[RunState]]`
加新状态时只改这张表 + 补一条单测,不改 `runtime.py`
## 2. Step 状态机
```
PENDING ──▶ DISPATCHED ──▶ RUNNING ──┬──▶ SUCCEEDED
▲ ├──▶ FAILED ──┐
│ ├──▶ TIMED_OUT┤
└──── 重试(attempt+1)◀───────────┴─────────────┘
└──▶ CANCELLED / SKIPPED
```
- `DISPATCHED`:控制平面已把步骤租给某个 Agent,等待 Agent 确认接手。
租约有超时——Agent 领了活就死了,租约到期步骤回到 `PENDING` 重新派发。
- 重试次数由工作流步骤的 `retry` 声明,**默认 0**。
危险步骤(刷写、Format)默认不重试:重试一次刷写可能把盘刷成砖。
## 3. 五级恢复阶梯
对应方案 §7.2。触发条件是**双通道观测**的判定结果,不是单一信号:
| 级别 | 动作 | 前提 | 典型触发 |
|---|---|---|---|
| L1 | `AGENT_SOFT` Agent 优雅停止/软重启进程 | Agent 心跳还在 | 测试脚本卡死、进程僵死 |
| L2 | `OS_REBOOT` 通过 OS 远程通道重启 | 主机网络还通 | Agent 进程崩溃、驱动异常 |
| L3 | `OOB_RESET` 带外触发主板 Reset | 带外控制器在线 | 心跳超时 + 带外仍在线(蓝屏典型) |
| L4 | `OOB_ATX_POWER` 带外模拟 ATX 长按关机再开机 | 带外控制器在线 | Reset 无效(挂在 BIOS/固件态) |
| L5 | `OOB_AC_CYCLE` 整机 AC 断电 → 安全间隔 → 上电 | 带外控制器在线 | ATX 无效;也是 DUT 掉盘的最后手段 |
| — | `FREEZE` 冻结现场,停止自动恢复 | — | 超过限定次数;或**数据完整性失败立即触发** |
**关键规则(`recovery.py` 里是硬编码的,不给配置覆盖):**
1. **数据完整性失败直接跳到 FREEZE**,不走阶梯。
哈希/读回比较不一致意味着现场有价值,任何重启都可能毁掉证据。
2. **带外控制器离线时,L3-L5 不可用**,直接降级到 FREEZE 并标记
`INFRA_FAILURE`——不能因为带外没接就把 DUT 判成坏盘。
3. **每一级恢复都要留证**:恢复前抓画面/串口/温度,恢复后验证 DUT 重新枚举。
`recovery_actions` 表记录每一级的 trigger / outcome / evidence_uri。
4. **恢复成功 ≠ 步骤成功**。恢复只是把系统救回可执行状态,
原步骤按检查点语义决定是续跑还是重跑。
## 4. 检查点与断点续跑
检查点在**步骤边界**创建,不在步骤中间——中间态无法保证幂等。
```
loop_index=37, step=fio-workload, state=SUCCEEDED
└─▶ checkpoint 写入:{loop_index: 37, next_step: verify-enumeration, dut_fw: "A"}
```
恢复后的续跑规则:
| 挂在哪 | 恢复后 |
|---|---|
| 步骤已 `SUCCEEDED`,检查点已落 | 从下一步继续 |
| 步骤 `RUNNING` 时挂了,步骤幂等(`idempotent: true` | 重跑该步骤 |
| 步骤 `RUNNING` 时挂了,步骤非幂等(默认,如刷写) | **不重跑**,标记 `INCONCLUSIVE`,进入人工确认 |
| 步骤是循环体中的一环 | 回到该 `loop_index` 的起点重跑整轮 |
非幂等步骤不自动重跑,是这一层最保守也最重要的默认值。
"自动重试把盘刷坏"是这个产品最容易砸招牌的失败模式。
## 5. 心跳与失联判定
```
Agent 心跳间隔 5s
├─ 15s 无心跳 → 主机 DEGRADED,记事件,不动作
├─ 30s 无心跳 → 判定失联,查带外:
│ ├─ 带外在线 + 主机有电 → 走恢复阶梯 L3
│ ├─ 带外在线 + 主机无电 → INFRA_FAILURE(供电问题,不是 DUT
│ └─ 带外也离线 → INFRA_FAILURE + FREEZE(不能瞎判)
└─ 心跳恢复 → 校验 Run 状态一致性,续跑
```
阈值在 `config.py`,但**判定逻辑在 `state_machine.py` 里是纯函数**
输入 `(last_heartbeat_age, oob_online, oob_power_state, now)`,输出判定。
所以"30 秒超时"这种行为可以不等 30 秒就测出来。
## 6. 怎么给状态机加东西(后续几波会反复做)
1.`states.py` 加状态/事件枚举 + 改转移表
2.`state_machine.py::decide()` 加分支,返回新的 `Effect`
3.`runtime.py` 加该 `Effect` 的施加逻辑(唯一碰 I/O 的地方)
4.`tests/test_state_machine.py` 加纯函数单测——不起库、不 sleep
如果第 2 步发现需要在 `decide()` 里做 I/O,说明抽象漏了,
应该把需要的数据加进 `Snapshot`,而不是在纯函数里开个口子。
+123
View File
@@ -0,0 +1,123 @@
# 工作流规范:SOP → 可执行状态机
> **当前状态**:这是待实现的发布与物化规范。数据库中已经保存一份符合该结构的
> 演示工作流,但 `WorkflowSpec` 完整 schema 校验、YAML 发布器与 `planner.py`
> 尚未落地;当前 API 不接受任意工作流上传。
工作流必须是**确定性的结构化配置**,不是让大模型自由生成命令(方案 §6.3、§8.1)。
AI 可以把自然语言 SOP 草拟成下面这份 YAML,但**必须过 schema 校验 + 人工发布**才能执行。
目标实现位置:`engine/planner.py`(物化)、`schemas.py::WorkflowSpec`(校验)
`workflows/fw-ab-regression.yaml`(版本化示例)。
## Schema
```yaml
key: fw-ab-regression # 全局唯一,改 key = 新工作流
name: 固件 A/B 回归
version: 3 # 每次发布 +1spec_hash 变了但 version 没变 → 拒绝发布
danger_level: high # none | low | high —— high 需要审批记录
source_sop: "客户X_固件回归SOP_v2.1(脱敏)"
params: # 运行时参数,带类型与默认值
loops: {type: int, default: 100, min: 1, max: 10000}
dut_serial: {type: string, required: true}
host: {type: string, required: true}
fw_a: {type: string, required: true} # firmware_artifact id
fw_b: {type: string, required: true}
resources: # 独占锁,PREFLIGHT 阶段获取,终态释放
- {type: dut, ref: params.dut_serial, exclusive: true}
- {type: test_host, ref: params.host, exclusive: true}
policy:
heartbeat_timeout_s: 30
max_recovery_per_loop: 3 # 单轮循环内恢复超过 3 次 → FREEZE
max_recovery_total: 20
on_data_integrity_failure: freeze # 硬编码值,写在这里只是为了显式
steps:
- key: preflight
type: precheck
adapter: safety
- key: flash-fw-a
type: command
adapter: nvme_cli
template: nvme_fw_download_commit # 只能引用已注册的签名模板
params: {firmware: "{{ params.fw_a }}", slot: 1, action: 3}
danger: high
timeout_s: 300
idempotent: false # 默认值,写出来是为了提醒:挂了不自动重跑
checkpoint: true
- key: regression-loop
type: loop
count: "{{ params.loops }}"
body:
- key: workload
type: command
adapter: fio
template: fio_seq_rw
timeout_s: 600
retry: 1
idempotent: true
- key: enumeration-check
type: device_check
adapter: nvme_cli
expect: {enumerated: true, link_speed_min: "8GT/s"}
- key: integrity-check
type: command
adapter: shell
template: sha256_readback
on_fail: freeze # 覆盖默认处置
checkpoint: true # 每轮结束落检查点
- key: report
type: report
adapter: builtin
```
## 步骤类型(MVP 六种)
| type | 语义 | 备注 |
|---|---|---|
| `command` | 执行签名命令模板 | 唯一能碰危险动作的类型 |
| `device_check` | 设备探针断言(枚举/固件版本/链路速率/SMART) | 不改变设备状态 |
| `wait` | 等待固定时长或条件 | 用于温度稳定、上电间隔 |
| `loop` | 固定次数循环,body 是步骤列表 | 支持嵌套一层 |
| `precheck` | 安全门禁 | 每个工作流的第一步都应该是它 |
| `report` | 生成报告与 Evidence Bundle | 内置适配器 |
企业版才加的:`http``parallel``subflow``approval``instrument`
这一波故意不做——`planner.py` 里遇到未知 type 直接拒绝发布,不静默跳过。
## 模板变量
只支持 `{{ params.X }}``{{ loop.index }}` 两种插值,**不支持表达式求值**。
理由:能求值就能注入。需要计算的场景写进适配器,不写进 YAML。
## 版本与 Git
工作流 YAML 进 `workflows/` 目录、进 Git。发布时控制平面记录:
```
workflows.spec 原文
workflows.spec_hash sha256(规范化后的 spec)
workflows.version 发布号
```
`spec_hash` 变了而 `version` 没变 → 拒绝发布。理由:A/B 对比的结论必须能追溯到
**具体哪一版流程**,否则"上周跑的和这周跑的是不是同一个流程"就说不清了。
## `on_fail` 的可选处置
| 值 | 行为 |
|---|---|
| `retry`(默认,受 `retry:` 次数约束) | 重试该步骤 |
| `fail_run` | 整个 Run 判 FAIL 并结束 |
| `continue` | 记失败,继续下一步(用于非关键采集步骤) |
| `freeze` | 立刻冻结现场,停止一切覆盖性动作 |
| `recover` | 进入恢复阶梯 |
数据完整性相关的步骤**只允许** `freeze`planner 校验时强制。