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
@@ -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 的默认执行后端。