# 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。
