commit c91a64fddb9c2578af0143234ed2738685f044ed Author: 苑帅 Date: Mon Jul 27 20:40:12 2026 +0800 chore(repo): initialize team collaboration repository diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..37a8505 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto + +*.py text eol=lf +*.sh text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.html text eol=lf +*.js text eol=lf +*.css text eol=lf + +*.docx binary +*.jpg binary +*.jpeg binary +*.png binary +*.webp binary diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ee92c8d --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## 改动内容 + +- + +## 原因与影响 + +- + +## 验证 + +- [ ] `make test` +- [ ] 相关演示或人工验收已完成 +- [ ] API、配置、数据结构或部署变化已同步文档 + +## 安全检查 + +- [ ] 未提交凭据、生产数据、数据库、日志或 Agent state +- [ ] 未绕过安全门禁、事件写路径或状态转移约束 +- [ ] 危险动作仍保持默认拒绝 + +## 风险与回滚 + +- 风险: +- 回滚: diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6a9d605 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,42 @@ +# This workflow becomes active only after an isolated Gitea Actions runner is +# deliberately registered. No runner is installed on the production server. +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + python-tests: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.12"] + defaults: + run: + working-directory: flashops + env: + PYTHONPATH: ${{ gitea.workspace }}/flashops/services/control-plane:${{ gitea.workspace }}/flashops/services/host-agent + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: flashops/services/control-plane/requirements.txt + + - name: Install dependencies + run: python -m pip install -r services/control-plane/requirements.txt + + - name: Run tests + run: python -m pytest services/control-plane/tests services/host-agent/tests -q + + - name: Compile Python modules + run: python -m compileall -q services/control-plane/flashops_control services/host-agent/flashops_agent diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5963d93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Operating systems and editors +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo + +# Python environments and caches +**/.venv/ +**/venv/ +**/__pycache__/ +**/.pytest_cache/ +**/.mypy_cache/ +**/.ruff_cache/ +**/*.egg-info/ +*.py[cod] +.coverage +htmlcov/ + +# JavaScript dependencies and build output +**/node_modules/ +**/.next/ +**/out/ +**/.turbo/ + +# FlashOps local runtime data +flashops/var/ +*.db +*.db-journal +*.db-shm +*.db-wal +*.log +agent-state.json + +# Secrets and machine-local configuration +**/.env +**/.env.* +!**/.env.example +!**/env.example +*.pem +*.key +*.p12 +*.pfx +*.local.md +*.env.local +credentials.json +secrets.json + +# Temporary release and backup artifacts +*.tmp +*.bak +*.tar +*.tar.gz +*.zip +*_backup/ +*_rollback/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5210914 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,111 @@ +# STORAGE LABOS 团队协作指南 + +本文是团队日常 Git 操作的简明入口;完整工程约束见 +[ProjectManual.md](ProjectManual.md) 第 13 章。 + +## 1. 首次加入项目 + +公开仓库允许匿名克隆;需要提交代码、Issue 或 Pull Request 的成员先注册账号并由管理员批准: + +```bash +git clone https://git.imagebrewing.com/yuanshuai/storage-labos.git +cd storage-labos/flashops +make setup +make test +``` + +不要通过聊天工具传递压缩包继续开发;Git 仓库是代码和文档的唯一版本来源。 + +## 2. 分支规则 + +- `main`:始终保持测试通过和可发布,禁止直接开发或强制推送。 +- `feat/`:功能开发。 +- `fix/`:缺陷修复。 +- `docs/`:纯文档变更。 +- `chore/`:构建、依赖、部署与仓库维护。 + +一项工作一个分支;不要把无关功能、生产配置和大规模格式化混在同一分支。 + +```bash +git switch main +git pull --ff-only +git switch -c feat/agent-step-lease +``` + +## 3. 提交前质量门 + +```bash +cd flashops +make test +make demo +``` + +回到仓库根目录后,只暂存本次工作涉及的文件: + +```bash +git status -sb +git add <明确的文件或目录> +git diff --cached --stat +git diff --cached --check +``` + +混合工作区不要直接使用 `git add -A`,避免把别人的改动、数据库或临时文件带入提交。 + +## 4. 提交格式 + +使用 Conventional Commits:`type(scope): subject`。 + +| 类型 | 用途 | 示例 | +|---|---|---| +| `feat` | 新功能 | `feat(agent): add step lease polling` | +| `fix` | 缺陷修复 | `fix(safety): reject stale host heartbeat` | +| `docs` | 文档 | `docs(manual): add agent deployment guide` | +| `test` | 测试 | `test(agent): cover token rotation` | +| `refactor` | 不改变行为的重构 | `refactor(events): isolate projection writer` | +| `perf` | 性能优化 | `perf(api): reduce timeline query cost` | +| `chore` | 工具、依赖和部署 | `chore(ci): add Python test matrix` | + +主题用祈使语气、简短明确;一次提交只表达一个完整意图。 + +## 5. 推送与 Pull Request + +```bash +git commit -m "feat(agent): add step lease polling" +git push -u origin "$(git branch --show-current)" +``` + +在 Gitea 创建 Pull Request,并写清:改了什么、为什么改、影响范围、验证结果和风险。 +至少一名团队成员评审且质量门通过后才能合并。当前 Gitea 未在生产服务器上启用自托管 +Runner,提交者必须在 PR 中附上本地测试结果;未来接入隔离 CI Runner 后再把自动检查设为 +必需。优先使用 Squash merge,PR 标题保持 Conventional Commit 格式;合并后删除远程功能分支。 + +评审者重点检查: + +1. 安全门禁、状态机和事件写路径是否被绕过; +2. 是否新增或修改测试; +3. API、数据结构、配置或部署变化是否同步更新手册; +4. 是否包含凭据、生产数据、日志、构建产物或大文件; +5. 危险动作是否仍为默认拒绝。 + +## 6. 冲突与回退 + +功能分支同步 `main` 时使用团队统一方式;首阶段建议 rebase: + +```bash +git fetch origin +git rebase origin/main +``` + +仅允许对自己尚未合并的功能分支执行 `git push --force-with-lease`;严禁对 `main` 强推。 +已合并变更需要撤销时使用 `git revert `,不要改写共享历史。 + +## 7. 绝不能提交的内容 + +- `.env`、密码、Token、SSH 私钥、证书私钥; +- `flashops/var/`、SQLite 数据库、Evidence Bundle 和日志; +- `.venv/`、`node_modules/`、缓存和本机构建产物; +- `~/.flashops/agent-state.json` 或任何 Agent 原始 bearer token; +- 未脱敏的客户数据、设备序列号清单和生产备份。 + +如果凭据误入提交:立即停止推送、通知管理员轮换凭据,再清理历史;仅删除当前文件不等于 +从 Git 历史中删除。 diff --git a/FlashOps_存储固件回归测试值守机器人_产品开发与规划方案_v1.0.docx b/FlashOps_存储固件回归测试值守机器人_产品开发与规划方案_v1.0.docx new file mode 100644 index 0000000..2283ab9 Binary files /dev/null and b/FlashOps_存储固件回归测试值守机器人_产品开发与规划方案_v1.0.docx differ diff --git a/ProjectManual.md b/ProjectManual.md new file mode 100644 index 0000000..f895f60 --- /dev/null +++ b/ProjectManual.md @@ -0,0 +1,1606 @@ +# FlashOps 项目开发手册 (Project Manual) + +本文档用于 FlashOps / STORAGE LABOS 项目的开发、测试、部署、运维与交接。内容以 +2026-07-27 的实际源码和生产环境为准;目标架构与尚未实现的能力会明确标注,避免把原型能力 +误认为真实硬件能力。 + +> **文档状态**:V1.2 · 已与当前代码、Git 仓库和云端部署边界核对 +> +> **生产地址**: +> +> **团队 Git**: +> +> **后端代号**:FlashOps +> +> **控制台品牌**:STORAGE LABOS +> +> **安全提醒**:本文不记录 SSH 密码、Gitea 管理员密码、私钥或其他明文凭据。 + +--- + +## 目录 + +1. [项目概述](#1-项目概述) +2. [快速开始](#2-快速开始) +3. [系统架构](#3-系统架构) +4. [核心业务流程与状态机](#4-核心业务流程与状态机) +5. [安全门禁与危险动作](#5-安全门禁与危险动作) +6. [数据库与领域模型](#6-数据库与领域模型) +7. [API 接口设计](#7-api-接口设计) +8. [前端控制台](#8-前端控制台) +9. [工作流、Agent 与适配器](#9-工作流agent-与适配器) +10. [证据、失败分析与指标](#10-证据失败分析与指标) +11. [配置与环境变量](#11-配置与环境变量) +12. [腾讯云生产部署与运维](#12-腾讯云生产部署与运维) +13. [测试、质量门与开发规范](#13-测试质量门与开发规范) +14. [已知边界与演进路线](#14-已知边界与演进路线) +15. [故障排查](#15-故障排查) +16. [术语表与 FAQ](#16-术语表与-faq) +17. [变更记录](#17-变更记录) + +--- + +## 1. 项目概述 + +### 1.1 背景 + +固件回归测试通常跨越数小时甚至数天,期间可能发生 Agent 崩溃、操作系统蓝屏、测试盘掉盘、 +网络中断或整机失联。传统做法依赖工程师通宵值守、手工重启、记录截图和拼装报告,成本高, +而且很难证明“当时到底发生了什么”。 + +FlashOps 的目标是把一条真实实验室 SOP 变成可执行、可恢复、可审计、可取证的状态机: + +- 自动执行固件 A/B 回归与循环测试; +- 在破坏性操作前阻止错盘、系统盘和未授权 DUT; +- 主机失联后通过独立带外通道逐级恢复; +- 从安全检查点继续,而不是盲目重跑非幂等步骤; +- 统一记录控制平面、Agent、OOB 和人工操作事件; +- 生成可回放的 Evidence Bundle; +- 用无人值守完成率和人工触碰次数量化节省的值守成本。 + +### 1.2 主要用户 + +| 用户 | 主要任务 | +|---|---| +| 固件 / SSD 测试工程师 | 创建回归任务、查看实时进度、分析失败证据 | +| 实验室管理员 | 管理 DUT、测试主机、带外控制器和危险动作授权 | +| 研发工程师 | 对比固件版本、复现失败、读取时间线与环境指纹 | +| 测试负责人 | 查看 UCR、证据完整率、阻断问题和工位利用情况 | +| 运维人员 | 维护控制平面、数据库、证书、日志和备份 | + +### 1.3 当前可运行基线 + +当前版本已经具备一条无需真实硬件即可端到端运行的纵向链路: + +```text +创建任务 + → 六项安全预检 + → DUT / 测试主机资源加锁 + → N 次模拟回归循环 + → 注入 Agent 心跳丢失 + → L1 Agent 软恢复失败 + → L2 OS 重启失败 + → L3 带外 Reset 恢复 + → 校验环境指纹并从检查点续跑 + → 生成 Evidence Bundle + → 控制台展示状态与事件时间线 +``` + +### 1.4 能力状态 + +| 能力 | 状态 | 说明 | +|---|---|---| +| FastAPI 控制平面 | 已实现 | 任务、预检、状态、人工操作、看板接口 | +| 14 张持久化表 | 已实现 | SQLite 可直接建库和灌种子数据 | +| Run 事件溯源 | 已实现 | 状态变化统一经 `events/recorder.py` 写入 | +| Run 状态转移约束 | 已实现 | 非法转移抛出异常,终态不可继续 | +| 六项安全门禁 | 已实现 | 序列号、授权、系统盘、固件、Host、OOB | +| L1–L5 恢复决策 | 已实现 | 演示链路实际执行到 L3;L4/L5 为策略能力 | +| 检查点续跑 | 模拟实现 | 当前保存循环边界检查点 | +| Evidence Bundle | 最小实现 | `manifest.json` + `events.ndjson` | +| 控制台 | 部分动态 | 任务中心和实时运行接 API,其余主要为产品原型 | +| 生产部署 | 已实现 | 腾讯云 CVM + systemd + Nginx + HTTPS | +| 团队 Git 服务 | 已实现 | Gitea 1.27 + HTTPS + PR/Issue + COS 定时备份 | +| 独立 Host Agent | 部分实现 | 注册、token 摘要、主机探针、心跳及状态老化已实现;Step 租约待实现 | +| 真实 NVMe / OOB 接入 | 未实现 | 尚未连接可牺牲 DUT、PDU 或 KVM | +| 失败指纹聚类执行逻辑 | 未实现 | 数据模型和界面已定义,服务逻辑待接入 | +| 应用级账号与 RBAC | 未实现 | FlashOps 预览站当前匿名开放;接真实硬件前必须恢复认证与审批 | + +### 1.5 系统边界 + +当前版本是“可演示、可开发、可部署的控制平面基线”,不是已经可以对真实盘执行刷写和 +Sanitize 的生产硬件平台。以下行为不得通过修改模拟数据来伪装完成: + +- 不得宣称 Step 租约、真实命令执行和真实 OOB 已经交付; +- 不得把 `sim://` 的固件、OOB 和证据 URI 当作真实硬件结果; +- 匿名预览环境不得接入真实硬件、客户数据或有效危险命令;接入前必须启用应用级认证、 + 审批和命令模板白名单; +- 不得直接把生产部署的 SQLite 单机形态扩展为多 worker; +- 不得绕过安全门禁向真实设备下发自由文本命令。 + +### 1.6 技术栈 + +| 层 | 技术 | 当前用途 | +|---|---|---| +| 后端 | Python 3.9+ / FastAPI | HTTP API 与应用生命周期 | +| ORM | SQLAlchemy 2.x Async | 领域模型、事务和查询 | +| 开发数据库 | SQLite + aiosqlite | 零外部依赖启动 | +| 目标数据库 | PostgreSQL 16 + asyncpg | 多工位生产形态,尚未切换 | +| 服务进程 | Uvicorn | ASGI 运行时 | +| 校验 | Pydantic 2.x | API 请求体校验 | +| 工作流配置 | YAML / JSON | SOP 与步骤定义 | +| 测试 | pytest / pytest-asyncio / httpx | 纯函数与 API 端到端测试 | +| 前端 | 静态 HTML + Design System JS/CSS | 产品控制台原型和部分 API 接入 | +| 生产守护 | systemd | 单 worker 服务、开机自启、故障重启 | +| 入口 | 宝塔 Nginx | HTTPS、反代和安全响应头;FlashOps 预览站匿名开放 | +| Git 协作 | Gitea 1.27 | 仓库、提交历史、分支、Issue、Pull Request 与成员管理 | +| 证书 | Let's Encrypt / Certbot | HTTPS 与自动续期 | + +### 1.7 目录结构 + +```text +STORAGE LABOS/ +├── .github/ PR 模板与 CI 工作流模板 +├── .gitignore / .gitattributes 忽略规则与跨平台文本规则 +├── README.md Git 仓库首页 +├── CONTRIBUTING.md 团队 Git 协作入口 +├── ProjectManual.md 本手册 +├── flashops/ 后端与技术文档 +│ ├── Makefile 本地开发统一入口 +│ ├── README.md 项目短说明和能力状态 +│ ├── docker-compose.yml 目标生产拓扑草案 +│ ├── deploy/ 当前腾讯云生产配置 +│ │ ├── README.md +│ │ ├── flashops.service +│ │ ├── nginx-http.conf +│ │ ├── nginx-https.conf +│ │ └── certbot-reload-nginx.sh +│ ├── docs/ 架构、协议、状态机、ADR +│ │ ├── architecture.md +│ │ ├── domain-model.md +│ │ ├── state-machine.md +│ │ ├── workflow-spec.md +│ │ ├── agent-protocol.md +│ │ ├── adapter-sdk.md +│ │ └── decisions/ +│ ├── services/control-plane/ +│ │ ├── requirements.txt +│ │ ├── flashops_control/ +│ │ │ ├── api/ HTTP 路由 +│ │ │ ├── engine/ 状态转移与恢复决策 +│ │ │ ├── events/ 事件写入口 +│ │ │ ├── evidence/ 证据包生成 +│ │ │ ├── models/ 14 张表 +│ │ │ ├── safety/ 六项安全门禁 +│ │ │ ├── services/ Run 编排和模拟执行器 +│ │ │ ├── cli.py 建库、种子、演示命令 +│ │ │ ├── config.py 配置入口 +│ │ │ ├── db.py 异步会话与 SQLite 并发保护 +│ │ │ └── main.py FastAPI 应用入口 +│ │ └── tests/ +│ ├── services/host-agent/ +│ │ ├── README.md 独立 Agent 运行与配置 +│ │ ├── flashops_agent/ 注册、指纹、凭据和心跳客户端 +│ │ └── tests/ +│ └── var/ 本地数据库、对象和证据(不入库) +└── 前端UI八页面完成/ 控制台静态页面与设计系统 + ├── Dashboard.dc.html + ├── Tasks.dc.html + ├── LiveRun.dc.html + ├── Evidence.dc.html + ├── Failures.dc.html + ├── Assets.dc.html + ├── Compare.dc.html + ├── Workflows.dc.html + ├── ConsoleNav.dc.html + ├── ds-base.js / support.js + └── _ds/ 设计系统资源 +``` + +### 1.8 服务地址与端口 + +| 环境 | 地址 / 端口 | 用途 | +|---|---|---| +| 本地开发 | `http://localhost:8000` | API 与控制台同源服务 | +| 本地控制台 | `/console/Dashboard.dc.html` | 控制台入口 | +| 本地 API 文档 | `/docs` | FastAPI OpenAPI UI | +| 生产内部监听 | `127.0.0.1:18080` | Uvicorn,仅服务器本机可访问 | +| 生产公网 | `https://flashops.imagebrewing.com` | Nginx HTTPS 入口 | +| 团队 Git | `https://git.imagebrewing.com` | Gitea 页面、HTTPS clone、PR 与成员管理 | +| 生产 HTTP | `80` | ACME challenge;其他请求跳转 HTTPS | +| 生产 HTTPS | `443` | TLS + 反向代理;FlashOps 预览站无需登录 | + +--- + +## 2. 快速开始 + +### 2.1 环境准备 + +- macOS、Linux 或 WSL; +- Python 3.9 或更高版本; +- GNU Make; +- 本地运行不需要 Node、Docker、PostgreSQL 或 Redis。 + +### 2.2 首次初始化 + +```bash +cd "/Users/yuanshuai/Desktop/STORAGE LABOS/flashops" +make setup +``` + +`make setup` 会执行以下动作: + +1. 创建 `flashops/.venv`; +2. 安装 `services/control-plane/requirements.txt`; +3. 重建本地 `var/flashops.db`; +4. 创建 `var/objects/`; +5. 灌入模拟工作流、DUT、Host、OOB 和固件数据。 + +> `make setup` 内含 `db-reset`,会删除本地 `var/` 中的开发数据。不要在生产目录执行。 + +### 2.3 启动开发服务 + +```bash +make dev +``` + +打开: + +- 总览: +- 任务中心: +- 实时运行: +- API 文档: + +### 2.4 跑一条完整演示 + +```bash +make demo +``` + +演示默认运行 8 个循环,并注入一次心跳丢失。完成后会输出 Run ID 和 Evidence Bundle 路径。 + +也可以在控制台“任务中心”先执行安全预检,再创建任务,然后进入“实时运行”观察事件时间线。 + +### 2.5 运行测试 + +```bash +make test +``` + +当前基线共有 25 项测试,覆盖: + +- Run 状态转移; +- L1–L5 恢复阶梯选择; +- 系统盘 / 分区保护; +- 六项安全预检; +- 完整模拟 Run、故障恢复、Evidence Bundle; +- 暂停、继续、紧急停止与人工触碰审计; +- Agent 注册、token 轮换、Bearer 鉴权和本地凭据权限; +- 心跳状态老化:15 秒降级、30 秒离线。 + +### 2.6 常用 Make 命令 + +| 命令 | 作用 | +|---|---| +| `make setup` | 创建虚拟环境、安装依赖、重建开发库 | +| `make setup-py` | 只准备 Python 依赖 | +| `make db-reset` | 删除本地数据库和对象目录后重建 | +| `make seed` | 向空库灌入种子数据;已有工作流时不重复写 | +| `make demo` | CLI 跑完整模拟链路 | +| `make dev` | 启动 API 和同源控制台 | +| `make dev-agent` | 注册 Agent 并发送一次真实进程心跳 | +| `make dev-agent-loop` | 持续运行 Agent 心跳循环 | +| `make test` | 执行完整测试集 | +| `make e2e` | 只跑端到端纵向测试 | +| `make clean` | 清理本地 `var/`、缓存和 `__pycache__` | + +--- + +## 3. 系统架构 + +### 3.1 五层架构 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ 交互与集成层 静态控制台 · CLI · OpenAPI │ +├──────────────────────────────────────────────────────────────┤ +│ 控制平面 FastAPI · Safety · Run Service · Event Store │ +├──────────────────────────────────────────────────────────────┤ +│ 执行平面 当前:内置模拟器 + Agent 心跳;目标:Step 执行 │ +├──────────────────────────────────────────────────────────────┤ +│ 带外平面 当前:模拟 OOB;目标:KVM / PDU / ATX / AC │ +├──────────────────────────────────────────────────────────────┤ +│ 证据与智能层 统一事件流 · Evidence Bundle · Failure Signature│ +└──────────────────────────────────────────────────────────────┘ +``` + +### 3.2 依赖方向 + +```text +Browser / CLI ──HTTP──▶ Control Plane ◀──HTTP pull── Host Agent(部分落地) + │ │ + ├──▶ Database ├──▶ Adapters + ├──▶ Object Store └──▶ OOB + └──▶ Event Timeline +``` + +硬规则: + +1. Agent 不导入控制平面代码,只按 `docs/agent-protocol.md` 通信; +2. 控制平面不主动反连客户主机,Agent 采用拉模式; +3. 状态机纯逻辑不依赖 FastAPI、SQLAlchemy、文件系统或真实时钟; +4. API 路由只做校验与服务调用,不承载复杂业务决策; +5. 危险动作必须先过控制平面安全门禁; +6. Run 状态不得绕过 `events/recorder.py` 直接修改; +7. 日志正文和大文件进入对象存储,事件表只保存索引、摘要和 URI。 + +### 3.3 当前运行架构 + +```text +Internet + │ + ▼ +DNSPod: flashops.imagebrewing.com → 62.234.39.66 + │ + ▼ +Nginx :443 + ├── TLS 1.2 / 1.3 + ├── 安全响应头 + └── proxy_pass http://127.0.0.1:18080 + │ + ▼ + systemd: flashops.service + │ + ▼ + Uvicorn 单 worker / FastAPI + ├── SQLite: var/flashops.db + ├── Evidence: var/objects/ + └── Console: ../前端UI八页面完成/ +``` + +### 3.4 开发形态与目标形态 + +| 组件 | 当前开发 / 线上基线 | 目标多工位形态 | +|---|---|---| +| 数据库 | SQLite | PostgreSQL 16 | +| 事务并发 | 应用级串行 SQLite session | 数据库行锁与队列 | +| 进程数 | 1 worker | 多 worker / 多实例 | +| 事件总线 | 进程内任务 | Redis / NATS | +| 对象存储 | 本地目录 | MinIO / S3 / COS | +| 执行器 | Run 仍由内置模拟器执行;独立 Agent 已能注册和心跳 | Agent 领取并执行 Step | +| OOB | 模拟 | JetKVM / PDU / 主板控制器 | +| 认证 | 匿名预览,无应用账号 | OIDC / SSO + RBAC + 审批 | + +### 3.5 关键架构决策 + +`flashops/docs/decisions/` 记录已经接受的 ADR: + +- ADR-0001:Monorepo 与技术栈; +- ADR-0002:Run 采用事件溯源; +- ADR-0003:首版自研状态机,不依赖 Temporal / Airflow; +- ADR-0004:安全门禁放在控制平面,Agent 保持简单; +- ADR-0005:模拟器是一等产品能力,不是临时测试脚手架。 + +修改这些边界前,应新增 ADR,而不是只改代码。 + +--- + +## 4. 核心业务流程与状态机 + +### 4.1 创建任务 + +任务创建时会冻结以下信息: + +- 工作流 key、version 和 `spec_hash`; +- DUT、Host、固件 A、固件 B; +- 循环次数和是否注入故障; +- OS、Kernel、BIOS、Agent、工具链、DUT 序列号和型号; +- 环境指纹哈希; +- 创建人和创建时间。 + +创建后依次写入 `RUN_CREATED` 和 `RUN_QUEUED` 事件,再启动模拟执行任务。 + +### 4.2 Run 状态机 + +```text +QUEUED ──▶ PREFLIGHT ──▶ RUNNING ──▶ COMPLETED + │ │ │ + │ └──▶ REJECTED │ + │ ├──▶ PAUSED ──▶ RUNNING + │ ├──▶ RECOVERING ──▶ RUNNING + │ │ └──▶ FROZEN + └──────────────────────────┴──────────────▶ ABORTED +``` + +| 当前状态 | 允许转移到 | +|---|---| +| `QUEUED` | `PREFLIGHT`, `ABORTED` | +| `PREFLIGHT` | `RUNNING`, `REJECTED`, `ABORTED` | +| `RUNNING` | `RECOVERING`, `PAUSED`, `FROZEN`, `COMPLETED`, `ABORTED` | +| `RECOVERING` | `RUNNING`, `FROZEN`, `ABORTED` | +| `PAUSED` | `RUNNING`, `ABORTED` | +| `FROZEN` | 无,终态 | +| `COMPLETED` | 无,终态 | +| `ABORTED` | 无,终态 | +| `REJECTED` | 无,终态 | + +非法转移由 `engine/states.py::assert_run_transition()` 拒绝。 + +### 4.3 Step 状态机(目标契约) + +```text +PENDING → DISPATCHED → RUNNING → SUCCEEDED + ├──→ FAILED + ├──→ TIMED_OUT + ├──→ SKIPPED + └──→ CANCELLED +``` + +`run_steps` 表已经定义状态、attempt、租约、输入输出和错误字段,但当前内置模拟器主要在 Run +和循环层执行,完整 Step runtime / Agent 租约尚未接入。 + +### 4.4 恢复阶梯 + +| 级别 | 名称 | 条件与动作 | +|---|---|---| +| L1 | `L1_AGENT_SOFT` | Agent 在线时优雅停止或重启执行进程 | +| L2 | `L2_OS_REBOOT` | Host 网络仍在线时请求操作系统重启 | +| L3 | `L3_OOB_RESET` | OOB 在线时触发主板 Reset | +| L4 | `L4_OOB_ATX_POWER` | 模拟 ATX 长按关机并重新开机 | +| L5 | `L5_OOB_AC_CYCLE` | 整机 AC 断电,等待安全间隔后上电 | +| Freeze | `FREEZE` | 停止自动动作,保留现场并等待人工 | + +数据完整性失败不走逐级恢复,直接 `FREEZE`,防止自动动作覆盖关键证据。 + +### 4.5 检查点语义 + +当前模拟执行器在循环结束时保存: + +```json +{ + "loop_index": 3, + "next_step_key": "regression-loop", + "dut_fw": "B" +} +``` + +恢复成功后必须先确认环境指纹和物理状态仍符合预期,再写 `CHECKPOINT_RESUMED`。 +刷写、Format、Sanitize 等非幂等步骤默认不得因为进程重启自动重复执行。 + +### 4.6 暂停、继续与紧急停止 + +- 暂停:仅 `RUNNING → PAUSED`; +- 继续:仅 `PAUSED → RUNNING`; +- 紧急停止:任何非终态可进入 `ABORTED`; +- 每次人工操作先写 `HUMAN_TOUCH`,递增 `human_touches`,并把 + `unattended_completion` 设为 `false`; +- 紧急停止会释放资源锁并取消内置模拟任务。 + +### 4.7 SQLite 并发保护 + +模拟器与人工暂停可能同时写 `run_events`。SQLite 只允许一个 writer;如果两个会话同时读取 +`event_seq` 再写入,会发生唯一键冲突。当前 `db.py` 对 SQLite 的完整 `session_scope` 事务按事件 +循环串行化,保证事件序号和投影一致。 + +这项保护只适合单进程 / 单 worker 基线。切换 PostgreSQL 后不使用应用级锁,并需要增加 +PostgreSQL 并发集成测试。 + +--- + +## 5. 安全门禁与危险动作 + +### 5.1 默认拒绝原则 + +安全判断发生在控制平面,并且必须早于命令下发。Agent 只能执行已批准模板和经过 schema +校验的参数,不能接收来自界面的自由文本 shell 命令。 + +### 5.2 六项预检 + +| key | 门禁 | 通过条件 | +|---|---|---| +| `serial` | DUT 序列号白名单 | 期望序列号非空且等于现场发现序列号 | +| `destructive` | 破坏性写入授权 | `duts.allow_destructive = true` | +| `system_disk` | 系统盘 / 分区保护 | DUT 路径不命中系统盘及其任一分区 | +| `firmware` | 固件适配型号 | DUT 型号在固件适用列表中,或列表为空 | +| `host` | 测试主机与 Agent | Host 状态为 `ONLINE` | +| `oob` | 带外控制器 | 绑定 OOB 存在且状态为 `ONLINE` | + +六项全部通过,`passed` 才为 `true`。 + +### 5.3 系统盘保护 + +`safety/gates.py::_same_device()` 同时比较完整设备名和分区归属。例如: + +- 目标 `/dev/nvme0n1`,系统盘 `/dev/nvme0n1`:拒绝; +- 目标 `/dev/nvme0n1p2`,系统盘 `/dev/nvme0n1`:拒绝; +- 目标 `/dev/nvme1n1`,系统盘 `/dev/nvme0n1`:可继续检查其他门禁。 + +真实 Agent 接入时还应补充:挂载点、root filesystem、启动标志、Windows Disk Number、BDF +和序列号的现场交叉校验。 + +### 5.4 危险级别 + +| 级别 | 含义 | +|---|---| +| `none` | 只读、报告或普通控制动作 | +| `low` | 有状态变化但可逆 | +| `high` | 刷写、Format、Sanitize、断电等,需要审批与审计 | + +### 5.5 进入真实实验室前必须补齐 + +1. 命令模板注册表与 JSON Schema 参数校验; +2. 不可绕过的系统盘识别; +3. 应用账号、RBAC、危险动作审批和双人复核; +4. Agent 身份、短期令牌、请求签名与重放保护; +5. 审批人、模板版本、参数、执行结果和撤销动作审计; +6. 真实 OOB 的安全间隔、最大恢复次数和硬件熔断; +7. 真实 DUT 的可牺牲测试环境,禁止先在业务盘验证。 + +--- + +## 6. 数据库与领域模型 + +### 6.1 当前数据库 + +- 本地:`flashops/var/flashops.db`; +- 生产:`/data/wangzhan/app/storage-labos/flashops/var/flashops.db`; +- 驱动:`sqlite+aiosqlite`; +- 表由 SQLAlchemy `Base.metadata.create_all()` 创建; +- 应用启动时会确保表和种子数据存在; +- 当前无 Alembic 迁移,修改表结构时需要先补迁移方案。 + +### 6.2 14 张表 + +| 领域 | 表 | 作用 | +|---|---|---| +| 资产 | `oob_controllers` | 带外控制器、能力、状态、电源和温度 | +| 资产 | `test_hosts` | 操作系统、Agent、工具链、心跳和 OOB 绑定 | +| 资产 | `duts` | 序列号、型号、BDF、设备路径、危险授权和健康信息 | +| 资产 | `firmware_artifacts` | 版本、哈希、签名、适用型号和文件 URI | +| 工作流 | `workflows` | SOP spec、版本、哈希、危险级别和审批人 | +| 运行 | `runs` | 当前状态投影、环境指纹、进度、检查点和结论 | +| 运行 | `run_steps` | 步骤状态、attempt、租约、输入输出和错误 | +| 事件 | `run_events` | 每个 Run 内单调递增的 append-only 时间线 | +| 恢复 | `recovery_actions` | 触发原因、恢复级别、结果、详情和证据 URI | +| 调度 | `resource_locks` | DUT / Host 独占锁,防止并发踩踏 | +| 分析 | `failure_signatures` | 失败聚类签名和统计 | +| 分析 | `run_failures` | 单次失败实例及其分类 | +| 证据 | `evidence_bundles` | 证据包 URI、manifest、大小和完整率 | +| 审计 | `audit_log` | 操作者、动作、命令模板、参数、审批和结果 | + +### 6.3 事件溯源 + +```text +run_events(顺序真相) + │ + ├──投影──▶ runs + ├──投影──▶ run_steps + └──关联──▶ recovery_actions / evidence_bundles / failures +``` + +`runs` 是为了查询效率保存的当前投影,`run_events` 才是完整顺序真相。事件的 `seq` 由控制平面 +分配,不能用不同机器的时间戳代替排序。 + +### 6.4 关键字段 + +- `runs.spec_hash`:任务使用的工作流不可变版本; +- `runs.env_fingerprint` / `env_fingerprint_hash`:A/B 可比性和失败聚类输入; +- `runs.event_seq`:每个 Run 的下一事件序号来源; +- `runs.checkpoint`:安全续跑位置; +- `runs.unattended_completion` / `human_touches`:UCR 与人工介入统计; +- `duts.serial` / `device_path` / `allow_destructive`:危险动作三信号; +- `firmware_artifacts.sha256` / `signature`:固件完整性与来源; +- `evidence_bundles.completeness` / `missing_fields`:证据质量; +- `resource_locks` 的资源类型和资源 ID 唯一约束:防止同一资产被重复占用。 + +### 6.5 数据文件与 Git + +以下内容属于运行产物,不进入 Git: + +```text +flashops/var/ +flashops/.venv/ +flashops/.pytest_cache/ +*.db +*.db-journal +*.log +.env +.env.local +``` + +--- + +## 7. API 接口设计 + +### 7.1 基础约定 + +- 前缀:`/api/v1`; +- 数据格式:JSON; +- 时间:API 输出 ISO 8601 UTC 字符串; +- 开发文档:`/docs`; +- 当前应用内部没有用户认证中间件; +- 当前公网预览站未启用 Nginx Basic Auth,页面和普通 API 可匿名访问; +- 匿名预览只能使用模拟资产。进入真实硬件或多用户阶段前必须增加应用级认证、RBAC、 + 审批和审计上下文。 + +### 7.2 接口清单 + +| 方法 | 路径 | 作用 | +|---|---|---| +| GET | `/api/v1/health` | 服务健康检查 | +| POST | `/api/v1/preflight` | 对指定或默认资产执行六项预检 | +| POST | `/api/v1/runs` | 创建 Run 并启动模拟执行 | +| POST | `/api/v1/runs/demo` | 用默认参数创建演示 Run | +| GET | `/api/v1/runs?limit=30` | 查询最近 Run,limit 限制 1–100 | +| GET | `/api/v1/runs/{run_id}` | 查询 Run 当前投影和 Evidence Bundle | +| GET | `/api/v1/runs/{run_id}/events?after_seq=0` | 增量查询事件时间线 | +| POST | `/api/v1/runs/{run_id}/pause` | 人工暂停 RUNNING 任务 | +| POST | `/api/v1/runs/{run_id}/resume` | 人工继续 PAUSED 任务 | +| POST | `/api/v1/runs/{run_id}/emergency-stop` | 紧急停止非终态任务 | +| GET | `/api/v1/dashboard/summary` | 汇总任务数、活动数、完成数和 UCR | +| POST | `/api/v1/agent/register` | 首次注册或轮换 Agent token | +| POST | `/api/v1/agent/{agent_id}/heartbeat` | Bearer 心跳、主机状态和探针上报 | +| GET | `/api/v1/agent/hosts` | 查询 Host / Agent 在线投影,不返回 token | + +### 7.3 请求模型 + +#### PreflightBody + +```json +{ + "dut_id": null, + "host_id": null, + "firmware_id": null, + "discovered_serial": null +} +``` + +全部为空时使用第一组种子资产。`discovered_serial` 可用于模拟现场序列号不一致。 + +#### CreateRunBody + +```json +{ + "name": "固件 A/B 无人值守回归", + "workflow_id": null, + "dut_id": null, + "host_id": null, + "firmware_a_id": null, + "firmware_b_id": null, + "loops": 8, + "inject_failure": true, + "created_by": "console-demo" +} +``` + +API 当前允许 `loops` 为 1–500;工作流种子 spec 的产品目标上限为 10000,两者尚未统一。 + +#### HumanActionBody + +```json +{ + "actor": "engineer-name", + "reason": "检查暂停" +} +``` + +### 7.4 调用示例 + +本地: + +```bash +curl -sS http://localhost:8000/api/v1/health + +curl -sS -X POST http://localhost:8000/api/v1/preflight \ + -H 'Content-Type: application/json' \ + -d '{}' + +curl -sS -X POST http://localhost:8000/api/v1/runs \ + -H 'Content-Type: application/json' \ + -d '{"loops":8,"inject_failure":true,"created_by":"manual"}' +``` + +公网预览环境可直接访问: + +```bash +curl https://flashops.imagebrewing.com/api/v1/health +``` + +### 7.5 常见状态码 + +| 状态码 | 场景 | +|---|---| +| 200 | 查询或动作成功 | +| 201 | Run 或 Agent 注册成功 | +| 400 | 资产或工作流数据无法解析 | +| 401 | Agent 注册口令或 Agent bearer token 无效 | +| 404 | Run / DUT / Host 等资源不存在 | +| 409 | 当前状态不允许暂停、继续或停止 | +| 422 | Pydantic 请求参数校验失败 | +| 503 | 生产环境尚未配置 Agent 注册口令 | +| 500 | 应用未处理异常或数据约束冲突 | + +--- + +## 8. 前端控制台 + +### 8.1 挂载方式 + +`main.py` 把后端仓库的同级目录 `前端UI八页面完成/` 挂载到 `/console`: + +```text +STORAGE LABOS/ +├── flashops/ +└── 前端UI八页面完成/ +``` + +生产服务器必须保持同样的兄弟目录关系,否则根路径会跳到 `/docs`,控制台路径返回 404。 + +### 8.2 核心页面 + +| 页面 | 文件 | 当前状态 | +|---|---|---| +| 总览 | `Dashboard.dc.html` | 产品级静态样例,看板 API 尚未全面接入 | +| 任务中心 | `Tasks.dc.html` | 已接预检与创建 Run API | +| 实时运行 | `LiveRun.dc.html` | 已接 Run、事件、演示、暂停、继续、急停 API | +| 证据浏览器 | `Evidence.dc.html` | 主要为静态样例,真实 Evidence 下载待接 | +| 失败中心 | `Failures.dc.html` | 静态产品原型,聚类服务待实现 | +| 资产中心 | `Assets.dc.html` | 静态产品原型,资产 CRUD 待实现 | +| 版本比较 | `Compare.dc.html` | 静态产品原型,真实 A/B 查询待实现 | +| 工作流模板 | `Workflows.dc.html` | 静态产品原型,编辑与发布 API 待实现 | + +`ConsoleNav.dc.html` 提供导航与全局停止交互原型;`Deck.dc.html` 和 `_template/` 用于展示稿, +不属于正式业务路由。 + +### 8.3 前端调用约定 + +- 页面与 API 同源,不配置独立 API Base URL; +- 所有动态请求使用相对路径 `/api/v1/...`; +- 前端代码不得保存任何生产密码或 Agent token; +- Run ID 应通过 URL 或浏览器状态传递; +- 事件增量读取使用 `after_seq`,不要每次重复拉取全部事件; +- 页面中静态样例必须用“演示 / 示例”标识,避免与真实数据混淆。 + +### 8.4 下一步前端接入优先级 + +1. 总览接 `/dashboard/summary` 与最近 Run; +2. 证据页接 Run Evidence Bundle 和真实事件; +3. 资产页增加只读 API,再增加受审批保护的写接口; +4. 工作流页面接版本、spec hash、发布和审批; +5. 失败中心接失败签名和人工分类; +6. 版本比较接真实环境指纹与指标; +7. 引入统一的 loading、empty、error、401、409 状态。 + +--- + +## 9. 工作流、Agent 与适配器 + +### 9.1 种子工作流 + +默认工作流: + +```text +key: fw-ab-regression +name: 固件 A/B 电源状态回归 +version: 3 +danger_level: high +``` + +步骤: + +1. `preflight`:安全预检; +2. `flash-fw-b`:高危、非幂等固件刷写,完成后检查点; +3. `regression-loop`:fio、设备枚举、完整性校验,循环边界检查点; +4. `report`:汇总报告。 + +每个发布版本保存完整 spec 和稳定 `spec_hash`。同 key 的修改应创建新版本,不覆盖历史版本。 + +### 9.2 工作流字段原则 + +- `key`:稳定机器标识; +- `version`:不可变整数版本; +- `danger_level`:工作流整体风险; +- `params`:类型、默认值、范围; +- `steps[].adapter`:调用的能力边界; +- `steps[].template`:命令模板,而不是任意命令文本; +- `idempotent`:是否允许安全重试; +- `checkpoint`:是否允许从此边界恢复; +- `on_fail`:retry / fail_run / continue / freeze / recover。 + +详见 `flashops/docs/workflow-spec.md`。 + +### 9.3 Host Agent 当前实现与目标模型 + +独立 Agent 已作为 `services/host-agent/flashops_agent` 实现注册、主机指纹发现、 +本地 `0600` 凭据文件、Bearer token 和心跳。控制平面只保存 token 的 SHA-256 摘要; +生产注册口令缺失时默认拒绝注册。心跳迟到 15 秒时 Host 自动进入 `DEGRADED`, +丢失 30 秒时进入 `OFFLINE`;安全预检会刷新状态后再判断 Host 是否可用。 +当前 Run 执行仍由内置模拟器完成。 + +下一阶段继续按拉模式实现: + +```text +Agent 注册 / 心跳 + → 拉取可执行 Step 租约 + → ACK 接手 + → 按模板执行 + → 上报输出摘要与对象 URI + → 完成 / 失败 / 超时 + → 租约续期或到期回收 +``` + +拉模式用于适配客户内网、NAT 和不允许控制平面反向连接的测试主机。 + +### 9.4 Adapter 边界 + +目标适配器包括: + +- `nvme_cli`:识别设备、固件下载 / commit、SMART、Format / Sanitize; +- `fio`:负载执行、指标和错误提取; +- `shell`:只允许登记模板; +- `oob`:Reset、ATX、AC、画面 / 电源状态; +- `builtin`:预检、等待、报告和证据处理。 + +模拟适配器与真实适配器必须通过同一套契约测试。真实实现不得在接口外增加隐藏行为。 + +### 9.5 幂等、租约和重放 + +- 每次 Step dispatch 带唯一 idempotency key; +- Agent 重复上报不能重复产生危险动作; +- 非幂等步骤中断后默认 `INCONCLUSIVE` 或人工确认; +- 租约到期只代表执行权回收,不代表命令一定未执行; +- 重派前必须根据模板类型和现场状态决定是否安全; +- 任何重放都必须在事件流中留下原因和原始 dispatch 引用。 + +--- + +## 10. 证据、失败分析与指标 + +### 10.1 Evidence Bundle 当前内容 + +每个完成 Run 的对象目录: + +```text +var/objects/runs// +├── manifest.json +└── events.ndjson +``` + +`manifest.json` 当前包含: + +- schema version; +- Run ID; +- workflow key / version / spec hash; +- environment fingerprint; +- checkpoint; +- event count; +- 文件路径和字节数。 + +`events.ndjson` 每行一条按 `seq` 排序的事件,包含时间、来源、kind、严重度、消息和 payload。 + +### 10.2 目标证据包 + +真实硬件阶段应补充: + +- Agent stdout / stderr 和工具原始输出; +- SMART、identify、PCIe、OS、驱动、BIOS 快照; +- fio JSON、校验 hash、失败 LBA 范围; +- 蓝屏 / panic dump、系统日志和屏幕证据; +- OOB 电源状态与画面指纹; +- 固件文件 hash 与签名验证结果; +- 审批、命令模板和参数; +- 文件级 SHA-256、缺失字段和生成器版本。 + +### 10.3 失败分类 + +| 分类 | 含义 | +|---|---| +| `DUT_DEFECT` | DUT / 固件自身缺陷 | +| `INFRA_FAILURE` | 主机、网络、供电、OOB 等基础设施问题 | +| `SCRIPT_FAILURE` | 测试脚本、适配器或参数问题 | +| `DATA_INTEGRITY` | 数据不一致,默认冻结现场 | +| `UNKNOWN` | 证据不足或尚未分类 | + +基础设施故障必须与 DUT 缺陷分开统计,否则会污染固件质量结论。 + +### 10.4 失败签名目标 + +建议由以下稳定要素归一化后计算 hash: + +```text +workflow step ++ normalized error codes ++ normalized log templates ++ host state ++ DUT enumeration state ++ data integrity state ++ recovery outcome ++ environment fingerprint +``` + +原始时间、随机 ID、绝对路径等高基数字段不应直接进入签名。 + +### 10.5 核心产品指标 + +| 指标 | 定义 | +|---|---| +| UCR | 无人值守完成 Run / 已完成 Run | +| Human Touches | Run 执行期间人工操作次数 | +| Evidence Completeness | 已收集必需证据字段 / 应收集字段 | +| Recovery Success Rate | 自动恢复成功次数 / 自动恢复尝试次数 | +| Infra False Positive Rate | 被误判为 DUT 缺陷的基础设施故障比例 | +| Reproduction Rate | 可由证据与最小步骤成功复现的失败比例 | + +当前 `/dashboard/summary` 已提供总 Run、活动 Run、完成 Run 和 UCR 的最小聚合。 + +--- + +## 11. 配置与环境变量 + +所有应用变量使用 `FLASHOPS_` 前缀。 + +| 变量 | 开发默认值 | 说明 | +|---|---|---| +| `FLASHOPS_ENV` | `development` | 环境标识 | +| `FLASHOPS_DATABASE_URL` | `sqlite+aiosqlite:///.../var/flashops.db` | SQLAlchemy DSN | +| `FLASHOPS_OBJECT_STORE_URL` | `.../var/objects` | 当前必须为本地路径 | +| `FLASHOPS_BUS_URL` | 空 | 目标 Redis / NATS 地址,当前未启用 | +| `FLASHOPS_AGENT_ENROLLMENT_TOKEN` | 空 | 控制面与 Agent 共享的首次注册口令;生产控制面缺失时拒绝注册 | +| `FLASHOPS_API_HOST` | `0.0.0.0` | 监听地址 | +| `FLASHOPS_API_PORT` | `8000` | 监听端口 | +| `FLASHOPS_CORS_ORIGINS` | `http://localhost:3000` | 逗号分隔 CORS 来源 | +| `FLASHOPS_ENGINE_TICK_S` | `0.5` | 引擎 tick 间隔 | +| `FLASHOPS_TIME_SCALE` | `1.0` | 模拟时间倍率;测试使用更小值 | + +### 11.1 时间策略默认值 + +| 配置 | 默认值 | +|---|---:| +| 心跳间隔 | 5s | +| 心跳降级 | 15s | +| 心跳丢失 | 30s | +| Step 租约 TTL | 60s | +| 租约拉取超时 | 20s | +| Step 默认超时 | 600s | +| 恢复后稳定等待 | 20s | +| AC 断电安全间隔 | 10s | +| 单循环最大恢复 | 3 | +| 单 Run 最大恢复 | 20 | + +当前这些时间策略在 `config.py::TimingPolicy` 中定义,还未全部开放为环境变量。 + +### 11.2 Host Agent 运行变量 + +| 变量 | 默认值 | 说明 | +|---|---|---| +| `FLASHOPS_AGENT_SERVER` | `http://127.0.0.1:8000` | 控制面基础地址 | +| `FLASHOPS_AGENT_HOST_ID` | 空 | 绑定已有 Test Host ID | +| `FLASHOPS_AGENT_HOST_NAME` | 本机 hostname | 按名称绑定或新建 Test Host | +| `FLASHOPS_AGENT_STATE_PATH` | `~/.flashops/agent-state.json` | 本地 bearer 凭据文件,权限固定为 `0600` | +| `FLASHOPS_AGENT_ENROLLMENT_TOKEN` | 空 | 与控制面一致的首次注册口令 | +| `FLASHOPS_AGENT_BASIC_USER` | 空 | 可选上游认证网关用户名;当前预览站未使用 | +| `FLASHOPS_AGENT_BASIC_PASSWORD` | 空 | 可选上游认证网关密码;当前预览站未使用 | + +用户名和密码必须同时配置。Agent 不会把 bearer token 输出到终端;首次注册响应中的原始 token +只写入本机状态文件。重新注册会轮换 token,使旧 token 立即失效。 + +### 11.3 配置原则 + +- 凭据不得写进仓库、本文档、systemd unit 或 Nginx 注释; +- 生产密码仅保存在受控凭据文件、密码管理器或服务器哈希文件; +- 修改生产环境变量后必须重启 `flashops.service`; +- 数据库和对象目录必须可写,其余应用目录应只读; +- SQLite URL 使用四个斜杠表示绝对路径; +- 多 worker 前必须先切换 PostgreSQL 和外部事件总线。 + +--- + +## 12. 腾讯云生产部署与运维 + +### 12.1 生产资源 + +| 项目 | 当前值 | +|---|---| +| 云厂商 | 腾讯云 CVM | +| 操作系统 | Ubuntu 24.04 | +| 公网 IP | `62.234.39.66` | +| 域名 | `flashops.imagebrewing.com`、`git.imagebrewing.com` | +| DNS | DNSPod A 记录,TTL 600 | +| 反向代理 | 宝塔 Nginx | +| 应用服务 | `flashops.service` | +| 应用用户 | `ubuntu` | +| 内部端口 | `127.0.0.1:18080` | +| 应用根目录 | `/data/wangzhan/app/storage-labos` | +| 持久化目录 | `/data/wangzhan/app/storage-labos/flashops/var` | +| Nginx vhost | `/www/server/panel/vhost/nginx/flashops.imagebrewing.com.conf` | +| 证书 | `/etc/letsencrypt/live/flashops.imagebrewing.com/` | +| 发布包备份 | `/data/wangzhan/backups/flashops-release-20260727.tar.gz` | +| Git 服务 | Gitea 1.27.0 Docker 容器,回环端口 `127.0.0.1:13000` | +| Git 实时数据 | `/data/gitea/data`,本地文件系统 | +| Git 云端备份 | `/chucun/wangzhan-production/backups/gitea/`,每日定时 dump + SHA-256 | + +初次证书签发于 2026-07-27,初始到期日为 2026-10-25。Certbot 已配置自动续期,并通过 +`--dry-run` 验证;续期成功后会执行 `/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh`。 + +> Agent 注册 / 心跳能力已在本地代码与进程联调中通过,但本节所述线上版本尚未发布该增量。 +> 发布前需创建 root-only `/etc/flashops/flashops.env` 并配置 +> `FLASHOPS_AGENT_ENROLLMENT_TOKEN`;不得把注册口令写进 systemd unit 或仓库。 + +### 12.2 生产安全边界 + +- Uvicorn 仅监听回环地址,不直接暴露 18080; +- Nginx 统一处理 TLS;FlashOps 当前作为匿名半成品预览开放; +- ACME challenge 路径不要求认证,以支持自动续期; +- systemd 启用 `NoNewPrivileges`、`PrivateTmp`、`ProtectSystem=full` 和 `ProtectHome`; +- 仅 `flashops/var` 允许应用写入; +- 当前 SQLite 生产基线固定一个 worker; +- 公开预览不得绑定真实 DUT、客户数据、真实 OOB 或有效危险命令;接入前必须先启用应用级 + 账号、RBAC、审批与命令模板白名单。 + +### 12.3 服务管理 + +```bash +sudo systemctl status flashops --no-pager -l +sudo systemctl restart flashops +sudo systemctl stop flashops +sudo systemctl start flashops +sudo systemctl enable flashops +``` + +健康检查: + +```bash +curl -fsS http://127.0.0.1:18080/api/v1/health +curl https://flashops.imagebrewing.com/api/v1/health +``` + +### 12.4 日志 + +```bash +# 应用实时日志 +sudo journalctl -u flashops -f + +# 最近 200 行 +sudo journalctl -u flashops -n 200 --no-pager + +# Nginx 访问 / 错误日志 +sudo tail -f /www/wwwlogs/flashops.imagebrewing.com.log +sudo tail -f /www/wwwlogs/flashops.imagebrewing.com.error.log +``` + +### 12.5 Nginx 与证书 + +```bash +sudo nginx -t +sudo systemctl reload nginx +sudo certbot certificates +sudo certbot renew --cert-name flashops.imagebrewing.com \ + --dry-run --non-interactive --no-random-sleep-on-renew +``` + +任何 Nginx 修改都必须先 `nginx -t`,只有成功后才能 reload。 + +### 12.6 发布流程 + +标准流程: + +1. 本地 `make test`; +2. 确认前端和后端兄弟目录结构; +3. 打包时排除 `.venv`、`var`、缓存和 `__pycache__`; +4. 上传到服务器临时目录; +5. 解压到独立 incoming 目录; +6. 安装服务器虚拟环境和依赖; +7. 保留生产 `var/`,不要用本地空目录覆盖; +8. 安装 / 校验 systemd unit; +9. 重启服务并轮询内部 health; +10. `nginx -t` 后 reload; +11. 从服务器和外部网络各验证一次匿名 HTTPS 200 和 API health; +12. 保存带 SHA-256 的发布包到 `/data/wangzhan/backups/`。 + +### 12.7 SQLite 备份 + +推荐使用 SQLite 在线备份 API,避免直接复制正在写入的数据库: + +```bash +sudo /data/wangzhan/app/storage-labos/flashops/.venv/bin/python - <<'PY' +import datetime +import sqlite3 +from pathlib import Path + +source = Path('/data/wangzhan/app/storage-labos/flashops/var/flashops.db') +stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S') +target = Path('/data/wangzhan/backups') / f'flashops-db-{stamp}.db' +target.parent.mkdir(parents=True, exist_ok=True) +with sqlite3.connect(source) as src, sqlite3.connect(target) as dst: + src.backup(dst) +print(target) +PY +``` + +恢复数据库是高影响操作:先停止服务,核对目标备份并额外备份当前数据库,再替换文件、修复 +`ubuntu:ubuntu` 所有权、启动服务并检查事件序号连续性。不得在未确认备份内容时直接覆盖。 + +### 12.8 公网预览访问策略 + +2026-07-27 起,`flashops.imagebrewing.com` 已取消 Nginx Basic Auth 和 IP 访问限制,匿名用户 +可以查看控制台并调用模拟 API。取消前的 Nginx 配置保留在: + +```text +/www/server/panel/vhost/nginx/flashops.imagebrewing.com.conf.before-public-20260727 +``` + +这是临时产品演示策略,不是最终安全方案。接入真实硬件或非公开数据前,不能只恢复 Basic Auth; +必须优先实现应用账号、RBAC、危险动作审批和审计上下文。 + +### 12.9 发布验收清单 + +- [ ] `flashops.service` 为 active; +- [ ] Nginx 为 active 且 `nginx -t` 成功; +- [ ] `getent ahostsv4 flashops.imagebrewing.com` 指向 `62.234.39.66`; +- [ ] HTTP 返回 301 到 HTTPS; +- [ ] HTTPS 证书域名正确且未过期; +- [ ] 未登录访问控制台最终返回 200; +- [ ] `/api/v1/health` 返回 `status=ok`; +- [ ] `/api/v1/dashboard/summary` 返回合法 JSON; +- [ ] 生产 `var/flashops.db` 和 `var/objects` 可写; +- [ ] 应用最近日志无 error; +- [ ] Certbot 模拟续期成功; +- [ ] 发布包和数据库备份已记录。 + +### 12.10 Gitea 团队 Git 服务 + +| 项目 | 当前值 | +|---|---| +| 页面与 clone 地址 | `https://git.imagebrewing.com` | +| 项目仓库 | `https://git.imagebrewing.com/yuanshuai/storage-labos` | +| 容器 | `gitea` / `gitea/gitea:1.27.0` | +| HTTP 监听 | `127.0.0.1:13000`,不直接暴露公网 | +| Git 传输 | HTTPS;当前不开放独立 SSH 端口 | +| 匿名访问 | 可查看 public 仓库与最近提交 | +| 注册 | 开放申请,但必须由管理员人工批准后才能登录 | +| 实时数据 | `/data/gitea/data`,不得放在 COSFS 上 | +| 备份 | 每日约 03:20 dump 到 `/chucun/wangzhan-production/backups/gitea/` | + +Gitea 管理员凭据只保存在本机被 `.gitignore` 排除的 `gitea-admin.env.local`,不写进手册、 +仓库或服务器部署文件。团队成员先在网页注册,再由管理员在“站点管理 → 用户账户”中批准, +随后把成员加入仓库协作者。 + +```bash +# 服务与健康 +sudo docker ps --filter name=gitea +curl -fsS http://127.0.0.1:13000/api/healthz + +# 日志 +sudo docker logs --tail 200 gitea + +# 备份定时器与手工备份 +sudo systemctl status flashops-gitea-backup.timer --no-pager +sudo systemctl start flashops-gitea-backup.service +``` + +生产服务器没有启用 Gitea Actions Runner。原因是 Runner 需要执行仓库中的任意代码,直接挂在 +同一台生产服务器会放大供应链风险;当前 PR 必须附本地 `make test` 结果。未来 CI 应部署到 +隔离机器或一次性 Runner,而不是把 Docker socket 交给生产仓库任务。 + +--- + +## 13. 测试、质量门与开发规范 + +### 13.1 当前测试基线 + +截至 2026-07-27: + +- 测试总数:25; +- 含 Agent 增量的完整测试集本地连续 20 轮全部通过; +- 腾讯云生产环境依赖下连续 20 轮全部通过; +- 服务器单轮约 1–2 秒; +- 证书续期 dry-run 通过; +- systemd 重启后 health 恢复通过; +- FlashOps 匿名 HTTPS 200、Gitea HTTPS 200 与两个证书域名验证通过。 + +### 13.2 测试分层 + +| 层 | 文件 | 重点 | +|---|---|---| +| 状态机 | `test_state_machine.py` | 合法 / 非法转移与终态 | +| 恢复策略 | `test_recovery.py` | Agent、网络、OOB、完整性触发 | +| 安全 | `test_safety.py` | 序列号、系统盘、分区和门禁组合 | +| 端到端 | `test_e2e_vertical_slice.py` | 创建、恢复、证据、暂停、继续、急停 | +| Agent 安全 | `test_agent_enrollment.py` | 生产缺少注册口令时默认拒绝 | +| Agent 状态 | `test_agent_status.py` | 新鲜、迟到、失联和主动降级心跳 | +| Agent 客户端 | `services/host-agent/tests/test_client.py` | HTTP 契约、401、凭据 `0600` | + +### 13.3 提交前检查 + +```bash +cd "/Users/yuanshuai/Desktop/STORAGE LABOS/flashops" +make test +make demo +``` + +如果改了部署配置,再检查: + +```bash +systemd-analyze verify deploy/flashops.service # 在 Linux 上执行 +sudo nginx -t # 在目标服务器执行 +``` + +### 13.4 开发规则 + +1. 状态变化必须追加事件; +2. 新状态必须更新枚举、转移表、文档和测试; +3. 新事件 kind 只增不改,历史事件已经落库; +4. 安全规则保持纯函数,拒绝发生在下发之前; +5. 危险步骤默认不可自动重试; +6. API 路由不堆业务逻辑; +7. 真实适配器与模拟适配器使用同一契约; +8. 大日志写对象存储,不塞进数据库 JSON; +9. 生产数据和凭据不进 Git; +10. 修改数据结构必须同时提供迁移和回滚说明; +11. 文档中的“已实现”必须能由代码或验收记录证明; +12. 生产发布必须保留可核验的归档包和数据库备份。 + +### 13.5 Git 仓库与团队协作 + +当前 Git 基线: + +| 项目 | 当前值 | +|---|---| +| 本地仓库根 | `STORAGE LABOS/` | +| 默认分支 | `main` | +| 远程 | `https://git.imagebrewing.com/yuanshuai/storage-labos.git` | +| 网页 | `https://git.imagebrewing.com/yuanshuai/storage-labos` | +| 可见性 | public:匿名可读;提交、Issue 和 PR 写操作必须登录 | +| 管理员 | `yuanshuai`;密码只在本机忽略文件中保存 | +| 团队规则入口 | 根目录 `CONTRIBUTING.md` | + +不要通过聊天工具反复传 ZIP 继续开发。团队以远程仓库为唯一代码与文档版本源,新成员执行: + +```bash +git clone https://git.imagebrewing.com/yuanshuai/storage-labos.git +cd storage-labos/flashops +make setup +make test +``` + +分支规则: + +- `main`:始终保持可测试、可发布,禁止直接开发和强制推送; +- `feat/`:功能; +- `fix/`:缺陷; +- `docs/`:文档; +- `chore/`:仓库、依赖、CI 和部署。 + +日常开发: + +```bash +git switch main +git pull --ff-only +git switch -c feat/agent-step-lease + +# 修改后先验证 +cd flashops +make test +make demo +cd .. + +# 只暂存本次相关文件;混合工作区不要直接 git add -A +git status -sb +git add <明确的文件或目录> +git diff --cached --stat +git diff --cached --check +git commit -m "feat(agent): add step lease polling" +git push -u origin "$(git branch --show-current)" +``` + +采用 Conventional Commits:`type(scope): subject`。 + +| 类型 | 用途 | 示例 | +|---|---|---| +| `feat` | 新功能 | `feat(agent): add step lease polling` | +| `fix` | 修复 | `fix(safety): reject stale host heartbeat` | +| `docs` | 文档 | `docs(manual): add gitea operations` | +| `test` | 测试 | `test(agent): cover token rotation` | +| `refactor` | 不改变行为的重构 | `refactor(events): isolate projection writer` | +| `perf` | 性能优化 | `perf(api): reduce timeline query cost` | +| `chore` | 工具、依赖、部署 | `chore(repo): initialize collaboration repository` | + +每项工作使用独立分支和 Pull Request。PR 必须说明改动、原因、影响、验证结果、风险和回滚; +至少一名团队成员评审后再合并。优先 Squash merge,合并后删除功能分支。只允许对自己的未合并 +分支使用 `--force-with-lease`,严禁对 `main` 强推;已共享的错误提交使用 `git revert` 撤销。 + +团队成员在 Gitea 注册后需要管理员人工批准。批准后在仓库“设置 → 协作者”中授予写权限; +不要共用管理员账号。离职或设备丢失时立即移除成员、撤销 token 并检查审计记录。 + +以下内容永不入库:`.env`、密码、token、私钥、证书私钥、Agent state、SQLite、Evidence、日志、 +生产备份、`.venv`、`node_modules` 和未脱敏客户数据。若凭据误入提交,先轮换凭据,再清理历史; +仅删除当前文件不能消除 Git 历史中的泄露。 + +### 13.6 CI 与 Pull Request 质量门 + +根目录 `.github/workflows/ci.yml` 已提供 Python 3.9 / 3.12 测试矩阵模板,执行控制平面与 +Host Agent 测试并编译全部 Python 模块。它可用于后续 GitHub 镜像或启用 Gitea Actions。 + +当前生产 Gitea 未配置 Actions Runner,因此这个工作流不会自动执行。这样做是刻意的:把 +Runner 和 Docker socket 放在同一台生产服务器,会允许仓库代码获得过大的服务器权限。 +现阶段 PR 必须附上本地 `make test` 与相关演示结果;下一阶段把 Runner 放到隔离机器或一次性 +执行环境,再把检查设为 `main` 的必需状态。 + +仍待补充的质量门:PostgreSQL 集成、Ruff / mypy、工作流 schema、依赖漏洞与 secret scan、 +Nginx / systemd 静态检查、模拟器长跑、发布包 SBOM 和签名。 + +--- + +## 14. 已知边界与演进路线 + +### 14.1 当前已知边界 + +| 边界 | 风险 | 处理方向 | +|---|---|---| +| SQLite 单 writer | 多并发会阻塞 | 当前单 worker;后续 PostgreSQL | +| 无 Alembic | 表结构难升级 | 引入版本化 migration | +| 无应用账号 / RBAC | 无细粒度审计与授权 | OIDC/SSO、角色、审批 | +| 匿名公网预览 | 页面和模拟 API 可被任何人调用 | 不接真实资产;真实阶段启用 OIDC/RBAC/审批 | +| Agent 无 Step 租约 | 只能注册和心跳,不能领取真实任务 | 实现 lease、ACK、续租和完成上报 | +| OOB 为模拟 | 无法验证真实断电恢复 | 接可牺牲硬件和独立网络 OOB | +| Step runtime 不完整 | 工作流只是部分执行 | planner/runtime/lease 落地 | +| Evidence 最小 | 不足以支撑真实缺陷结论 | 接工具原始输出、hash、dump 和画面 | +| 失败聚类未实现 | 失败中心为静态原型 | 归一化、签名、合并 / 拆分和复现任务 | +| 多数页面静态 | UI 与真实数据可能不一致 | 按 8.4 顺序接 API | +| 依赖范围较宽 | 重新安装可能拿到不同版本 | 生成并维护 production lock file | +| 无定时 DB 备份 | 数据恢复点不稳定 | systemd timer + 备份校验 + 保留策略 | + +### 14.2 建议演进顺序 + +#### Phase 1:独立 Agent 契约 + +- 已完成:注册、token 轮换、心跳、状态老化、主机探针和本地凭据; +- 待完成:租约、ACK、续租、结果上报; +- idempotency key 和重放保护; +- 模拟 Agent 与控制平面完全分进程; +- Agent 崩溃、网络断开、控制平面重启测试。 + +#### Phase 2:真实只读硬件接入 + +- NVMe identify / SMART / BDF / device path; +- Host 环境探针; +- OOB 只读电源和画面状态; +- 不执行刷写、Format、Sanitize 或断电。 + +#### Phase 3:受控危险动作 + +- 命令模板白名单; +- 审批、双人复核、短期授权; +- 可牺牲 DUT; +- 真实刷写、Reset、ATX、AC 阶梯; +- 完整 Evidence Bundle。 + +#### Phase 4:多工位与生产化 + +- PostgreSQL、外部事件总线、对象存储; +- 多工位调度与资源配额; +- SSO / RBAC / 审计; +- HA、监控、告警、备份和灾备; +- 评估 Temporal 是否只替换 runtime。 + +#### Phase 5:失败智能与版本决策 + +- 失败签名聚类; +- 环境可比性检查; +- 自动裁剪最小复现工作流; +- A/B 指标和证据门禁; +- AI 只生成带证据引用的摘要,不替代工程师结论。 + +--- + +## 15. 故障排查 + +### 15.1 公网返回 502 + +```bash +sudo systemctl status flashops --no-pager -l +sudo journalctl -u flashops -n 200 --no-pager +curl -v http://127.0.0.1:18080/api/v1/health +sudo nginx -t +sudo tail -n 100 /www/wwwlogs/flashops.imagebrewing.com.error.log +``` + +常见原因:服务未启动、虚拟环境缺依赖、端口不一致、数据库目录不可写、systemd 安全策略阻止写入。 + +### 15.2 公网返回 401 / 403 + +当前预览站允许匿名访问,首页和普通查询 API 不应由 Nginx 返回 401 / 403。遇到问题时: + +- 用无痕窗口访问,排除浏览器缓存的旧认证状态; +- 检查 Nginx 生效配置中是否残留 `auth_basic`、`allow` 或 `deny`; +- 区分普通页面与 Agent API:Agent 注册口令或 bearer token 无效仍会返回 401; +- 执行 `nginx -t`,确认无误后 reload; +- 查看站点 access/error 日志,确认状态码来自 Nginx、应用还是上游安全产品。 + +### 15.3 HTTPS 证书错误 + +```bash +getent ahostsv4 flashops.imagebrewing.com +sudo certbot certificates +sudo nginx -T | grep -A8 -B3 flashops.imagebrewing.com +openssl s_client -connect 62.234.39.66:443 \ + -servername flashops.imagebrewing.com /dev/null \ + | openssl x509 -noout -subject -issuer -dates +``` + +检查 DNS 是否指向正确 IP、证书路径是否存在、Nginx 是否已经 reload。 + +### 15.4 控制台 404 + +确认目录关系: + +```text +/data/wangzhan/app/storage-labos/ +├── flashops/ +└── 前端UI八页面完成/ +``` + +`main.py` 通过 `REPO_ROOT.parent / "前端UI八页面完成"` 查找控制台。 + +### 15.5 SQLite locked / 事件序号冲突 + +- 确认 Uvicorn 仍为一个 worker; +- 不要从多个进程同时直接操作同一个 SQLite 文件; +- 所有应用事务必须经 `session_scope()`; +- 所有 Run 状态写入必须经 `append_event()`; +- 检查是否有脚本绕过服务直接写库; +- 若需要多进程并发,迁移 PostgreSQL,不要继续堆 SQLite 锁。 + +### 15.6 服务不断重启 + +```bash +sudo systemctl show flashops -p NRestarts -p ExecMainStatus +sudo journalctl -u flashops --since '30 minutes ago' --no-pager +``` + +重点检查:`PYTHONPATH`、依赖安装、数据库 URL、目录权限、配置拼写和 18080 端口占用。 + +### 15.7 测试本机通过、服务器失败 + +比较: + +```bash +.venv/bin/python --version +.venv/bin/pip freeze +``` + +当前 requirements 使用范围约束而非完整 lock,同一天不同环境可能安装不同 FastAPI、Starlette、 +httpx 或 SQLAlchemy 版本。应先复现服务器依赖,再决定修代码或锁版本,不能只在本机降级掩盖并发缺陷。 + +### 15.8 Gitea 无法访问、注册或推送 + +```bash +sudo docker ps --filter name=^gitea$ +sudo docker logs --tail 100 gitea +curl -fsS http://127.0.0.1:13000/api/healthz +getent ahostsv4 git.imagebrewing.com +sudo nginx -t +``` + +- 公开仓库页面应允许匿名查看和克隆; +- 新成员注册后需要管理员在 Gitea 后台批准,未批准时不能登录; +- 推送必须使用已批准账号,不要把密码或访问令牌写入远程地址; +- 若备份失败,检查 `gitea-backup.service` 日志和 `/chucun` 挂载状态; +- Gitea 实时数据必须保留在 `/data/gitea/data`,不得迁移到 COSFS 挂载目录。 + +--- + +## 16. 术语表与 FAQ + +### 16.1 术语表 + +| 术语 | 含义 | +|---|---| +| DUT | Device Under Test,被测 SSD / NVMe 设备 | +| Host | 连接 DUT 并运行测试工具的主机 | +| Agent | 部署在 Host 上,拉取并执行已批准步骤的服务 | +| OOB | Out-of-Band,主机失联后仍可观测 / 控制的带外通道 | +| Run | 一次完整工作流执行实例 | +| Step | Run 中最小可调度执行单元 | +| Workflow | 版本化 SOP 和步骤定义 | +| Checkpoint | 可安全恢复的执行边界 | +| Evidence Bundle | 一次 Run 的证据清单与文件集合 | +| Event Sourcing | 用 append-only 事件作为状态变化真相 | +| Projection | 从事件派生的当前状态读模型 | +| UCR | Unattended Completion Rate,无人值守完成率 | +| Failure Signature | 归一化失败要素生成的聚类标识 | +| Idempotency | 同一请求重复执行不会产生额外副作用 | +| Lease | 控制平面临时授予 Agent 的步骤执行权 | +| BDF | PCIe Bus:Device.Function 地址 | + +### 16.2 FAQ + +#### 为什么生产还用 SQLite? + +当前是单工位、单 worker、内部演示基线,SQLite 能降低部署摩擦。它不是多工位最终方案;增加并发前 +必须切换 PostgreSQL。 + +#### 为什么不用 Airflow / Temporal? + +恢复语义跨越 Agent、OS、OOB 和物理供电,且非幂等步骤默认不重跑。首版用纯函数状态机更容易 +精确控制和测试。多工位跨节点调度成为瓶颈时,再评估用 Temporal 替换 runtime。 + +#### 为什么事件不能按时间戳排序? + +Host、OOB 和控制平面时钟可能不同,主机崩溃时还会丢失最后日志。控制平面分配的 `seq` 才能提供 +稳定、可重放的总顺序。 + +#### 为什么数据完整性失败直接冻结? + +自动重启、重跑或写盘可能覆盖最关键的现场。宁可暂停等待工程师,也不能为了完成率破坏证据。 + +#### 为什么 Agent 要用拉模式? + +客户测试主机通常位于 NAT、内网或防火墙后面。由 Agent 主动向控制平面发起连接更容易部署, +也减少暴露入站端口。 + +#### 为什么控制台和后端放在一起? + +当前静态控制台由 FastAPI 同源挂载,避免单独的前端构建和 CORS 配置,适合原型与演示。后续如果 +切换 Next.js 或独立 SPA,可以保持 `/api/v1` 契约不变。 + +#### 管理凭据在哪里? + +本文档不保存明文密码。Gitea 管理员、SSH 与云平台凭据通过本机受控文件、系统钥匙串或密码管理器 +交接;这些文件必须命中 `.gitignore`,不得进入提交历史。 + +#### 可以直接把 18080 暴露公网吗? + +不可以。该端口没有应用级认证和 TLS,必须只绑定 `127.0.0.1`,公网只走 Nginx 443。 + +--- + +## 17. 变更记录 + +### V1.2 — 2026-07-27 + +- 上线 `git.imagebrewing.com` Gitea 团队 Git 服务和公开项目仓库; +- 增加分支、提交、评审、密钥和本地验证规范; +- 使用 `/chucun/wangzhan-production/backups/gitea/` 保存每日 Gitea 校验备份; +- FlashOps 预览站取消 Nginx Basic Auth 与 IP 限制,普通页面和查询 API 可匿名访问; +- 明确匿名预览不得连接真实硬件、危险动作或非公开数据,正式生产前必须完成 SSO / RBAC。 + +### V1.1 — 2026-07-27 + +- 新增独立拉模式 Host Agent; +- 新增 Agent 注册、token 轮换、Bearer 心跳和 Host 状态查询 API; +- 控制平面只保存 token SHA-256 摘要,Agent 凭据文件权限固定为 `0600`; +- 生产环境缺少 Agent 注册口令时默认拒绝注册; +- Agent 保留可选网关 Basic Auth 能力,当前匿名预览环境不启用; +- 新增 Agent 客户端、注册安全、状态老化和进程联调测试;全量测试增至 22 项; +- Step 租约、ACK、事件、完成与产物上传仍为下一阶段。 + +### V1.0 — 2026-07-27 + +- 根据实际源码建立首版完整项目手册; +- 记录控制平面、14 张表、状态机、六项门禁和 Evidence Bundle; +- 记录 8 个核心控制台页面及真实 / 静态边界; +- 记录腾讯云、DNSPod、systemd、Nginx 和 Let's Encrypt 部署; +- 记录 SQLite 并发写入修复和单 worker 约束; +- 记录本机与服务器连续 20 轮测试结果; +- 给出生产发布、验收、备份、密码轮换和故障排查流程; +- 明确独立 Agent、真实硬件、RBAC、PostgreSQL 和失败聚类为后续工作。 + +--- + +## 文档维护要求 + +以下变化发生时,必须同步更新本文: + +- 新增 / 删除 API; +- 新增状态、事件种类或恢复级别; +- 修改表结构或迁移策略; +- 修改环境变量; +- 调整域名、端口、服务器路径或服务名; +- 新增真实 Agent、Adapter 或 OOB; +- 安全门禁、审批或认证方式变化; +- Evidence Bundle schema 变化; +- 生产发布和回滚流程变化; +- 能力从“模拟 / 原型”变为“真实已验收”。 + +文档里的完成状态必须由代码、测试或生产验收记录支撑。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..464f52c --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# STORAGE LABOS / FlashOps + +存储实验室固件回归测试的无人值守控制平面。当前仓库包含 FastAPI 控制面、独立 Host Agent、 +静态控制台、架构与部署文档,以及无需真实硬件即可运行的故障恢复演示。 + +## 快速开始 + +```bash +cd flashops +make setup +make test +make demo +make dev +``` + +启动后访问: + +- 控制台: +- API 文档: + +## 团队协作 + +- Git 网页: +- 克隆地址:`https://git.imagebrewing.com/yuanshuai/storage-labos.git` +- 项目完整说明:[ProjectManual.md](ProjectManual.md) +- Git、分支、提交与 PR 规则:[CONTRIBUTING.md](CONTRIBUTING.md) +- 后端与 Agent 状态:[flashops/README.md](flashops/README.md) + +所有功能从独立分支开发,经本地测试和 Pull Request 评审后合并到 `main`。禁止把 `.env`、 +Token、私钥、数据库、Evidence Bundle、虚拟环境或本机 Agent 凭据提交到仓库。 + +## 当前边界 + +当前真实 Host Agent 已支持注册、鉴权、主机探针、心跳和在线状态老化;实际 Run 仍由内置 +模拟执行器完成。Step 租约、真实 NVMe 危险动作、真实 OOB、应用级 RBAC 尚未交付。 diff --git a/Storage_LabOS_固件回归测试值守机器人_完整方案_V1.0.docx b/Storage_LabOS_固件回归测试值守机器人_完整方案_V1.0.docx new file mode 100644 index 0000000..9daceff Binary files /dev/null and b/Storage_LabOS_固件回归测试值守机器人_完整方案_V1.0.docx differ diff --git a/Storage_LabOS_顾问评估报告_可行性与竞品调研_V1.0.md b/Storage_LabOS_顾问评估报告_可行性与竞品调研_V1.0.md new file mode 100644 index 0000000..93d3534 --- /dev/null +++ b/Storage_LabOS_顾问评估报告_可行性与竞品调研_V1.0.md @@ -0,0 +1,360 @@ +# Storage LabOS · Regression Worker 顾问评估报告 + +**可行性分析 · 竞品调研 · 方案完善建议 · 总体规划** + +| | | +|---|---| +| 版本 | V1.0 | +| 日期 | 2026-07-27 | +| 评估对象 | 《Storage LabOS 固件回归测试值守机器人 完整方案 V1.0》 | +| 调研方法 | 方案全文评审 + 四组并行联网调研(直接竞品 / 量产与中国本土 / 隐形竞品与 DIY / 市场与客户),关键结论均有一手来源交叉验证;查不到之处明确标注 | +| 适用读者 | 两位联合创始人 | + +--- + +## 0. 执行摘要 + +### 总体结论:有条件立项(Conditional Go) + +方向对、切口对、时机对,但商业预期需要校准、若干关键判断需要修正。这不是一个"超大项目"的正确打开方式是先做大平台——恰恰相反,V1.0 里最值钱的判断(先做单工位值守机器人、不做测试仪、不碰协议合规)经过本次调研全部得到**证实和强化**。 + +### 六条核心判断 + +1. **差异化空白是真的。** 五家直接竞品(ULINK / OakGate / SANBlaze / Quarch / PyNVMe3)的公开资料中,"测试主机卡死自恢复、跨工具证据包、失败聚类、断点续跑、AI 判障"五项能力**均无成品级对应**;中国市场上也没有任何公司以独立软件产品形态销售"存储测试实验室编排系统"。空位真实存在——但窗口不会开三年(详见 §3.6)。 + +2. **差异化的表述必须修正。** DUT 侧的供电/复位/边带控制已是全行业标配(ULINK PSPA、SANBlaze 每盘位电控、Quarch 全系)。对外把"带外自救"讲成"给盘断电重启"会被当成已有功能。正确锚点:**测试主机失联的检测 → 分级恢复 → 从检查点续跑的闭环**——这在"通用主机 + 软件工具"型实验室(正是国内主控/模组厂的典型形态)价值最大。 + +3. **自组树莓派带外控制器在 2026 年是负资产。** JetKVM($69)、Sipeed NanoKVM($20 起)、GL.iNet Comet、PiKVM(HTTP API/IPMI/Redfish/ATX 控制齐全)已把"看画面 + 键鼠 + 重启 + 进 BIOS"做成商品。正确姿势:**兼容任意商品 KVM/PDU 作执行器**,把差异化全部押在商品盒子没有的四层软件——心跳判定语义、分级恢复策略引擎、与工作流状态联动的续跑、死机瞬间的证据留存。这四层目前没有任何现成开源实现。 + +4. **市场真实但是利基,融资策略要据此校准。** 可数目标客户:中国约 30-40 家、全球 60-90 家;按工位算 SAM:中国约 400-900 工位(对应年软件盘子约 ¥1,000-3,000 万),全球约 1,200-2,200 工位。这撑得起一家 10-50 人、年营收数千万级的专精产品公司,**撑不起典型 VC 规模叙事**——更适合天使 + 产业资本/设备商战投/专精特新路线,或 bootstrap。扩大盘子的路径是后期向邻近垂直(服务器 BMC/固件测试、GPU 老化、存储系统)延伸。 + +5. **时机是近五年最好的。** 存储超级周期:2024 年中国企业级 SSD 市场同比 +187.9%(IDC),2026Q1 企业级 SSD 价格环比 +80-100%,供需缺口预计持续到 2027 年底。客户在扩产、招测试人、买设备——测试产能是显性瓶颈,正是卖"无人值守"的窗口。反面:客户忙于出货,POC 时间难约,交付必须低打扰。 + +6. **"LabOS" 这个名字必须改。** 软件类目全球至少两个在营同名产品(labos.co 医疗 LIS、增材制造 LabOS),中国第 9 类商标已被注册且正处"撤三"争夺。主品牌应造独特词,"Storage LabOS"只可作描述性副题。 + +### 立项的四个前置条件 + +0. **领域合伙人("同学")或等效 SOP 来源在 30 天内书面落定**——注意:截至本报告日,"同学"只是意向对象,SOP、样品、客户引荐都尚未承诺。在此落定之前不启动产品工程(允许一个例外:2-4 周时间盒的零售盘技术样机,用作招募道具,且必须与合伙谈判、客户访谈并行推进——见 Phase 1a); +1. 30 天内完成 ≥3 家公司、5-10 人访谈,拿到 ≥2 套真实 SOP(防止过拟合单一公司样本); +2. 首个 POC 客户书面意向(可免费 POC + 验收付费条款); +3. 首客户工具链清点完成——每个工具标注 CLI / API / GUI-only,GUI-only 刷写工具风险有预案。 + +### 三个 Kill Criteria + +1. 访谈中"值守/复现"痛点排不进客户前三; +2. POC 8 周内无法稳定完成 20 循环 A/B,或注入故障恢复成功率 <60%; +3. 第二个客户起连接器复用率 <40%(项目制泥潭信号)。 + +### 本周就该做的五件事 + +1. 和"同学"把合作谈起来——先做**最小承诺测试**:本周约谈,请对方给一套脱敏 SOP(或口头带你走一遍流程)+ 30 分钟讲实验室值守现状。这一步对他成本近乎为零;若连这都推进不了,合伙就是假设而非事实,趁早启动替代路径(见 §4 Phase 0); +2. 约首客户测试主管做工具链清点(CLI/GUI 清单决定 MVP 技术路线); +3. 联系上海森弗(Saniffer)/深圳锐测(Reetest)询价 SANBlaze/ULINK/Quarch 在华价格(补定价锚点,顺带探渠道合作意向); +4. 启动新名字的商标检索(9/42 类 + 美国); +5. FMS 2026 是 8 月 4-6 日(圣克拉拉,距今一周)——来不及参展,若有美国朋友可代为观展收集竞品资料;国内主战场定为 2027 年 3 月深圳 CFMS|MemoryS。 + +--- + +## 1. 对 V1.0 方案的评价 + +### 1.1 做得好的地方(保留并强化) + +1. **战略克制**——"不做什么"清单(不做协议合规套件、不做 Gen5/6 高速背板、不做 EDA、AI 不碰危险命令)是全文档最值钱的部分,本次调研全部证实其正确性; +2. **北极星指标选择正确**——UCR + 释放工时,且配了"基础设施误报率"这种反直觉但关键的护栏指标; +3. **失败状态模型**(INFRA_* 与 DUT_* 分离)是真正的领域洞察,多数自研脚本死在这里; +4. **Evidence Bundle 具体到目录结构**,不是空话; +5. **竞品策略正确**——把竞品连接器化而不是对抗,调研证实 OakGate/SANBlaze 的 REST/Python API 完整,技术上可行; +6. **决策门设计**——每一步扩张都有前置证据要求; +7. **风险清单诚实**——自己点破了"Jenkins 套壳"和"定制泥潭"两大死法。 + +### 1.2 关键缺口清单 + +**P0(不补就无法立项/融资/启动)** + +| # | 缺口 | 说明 | +|---|---|---| +| 1 | 全文没有钱、没有时间 | 没有一个货币数字、一个时间盒。缺:POC 预算、18 个月资金需求、定价、收入预测、阶段时长。本报告 §5 已补一版供修订 | +| 2 | 没有市场规模 | "中型 SSD 团队"有多少家可数客户没有回答。本报告 §2.2 已补 | +| 3 | Windows GUI 工具风险被系统性低估 | MVP 排除"视觉操作",但大量厂商刷写工具(MPTool 类)是 GUI-only。若首客户刷写工具无 CLI,MVP 第一步就被卡死。预案:(a) 要求客户提供 CLI 版;(b) pywinauto/UIA 自动化作受控例外;(c) 换场景 | +| 4 | 缺 Phase 0 客户发现 | 立项动作直接从"拿 SOP 开发"开始;应先访谈 ≥3 家、拿 ≥2 套 SOP 再写代码 | +| 5 | 命名冲突 | "LabOS"必须改(详见 §2.2 市场部分与执行摘要) | + +**P1(POC 前后必须补)** + +| # | 缺口 | 说明 | +|---|---|---| +| 6 | 硬件策略过时 | 树莓派自组方案应降级为"兼容的执行器之一",改为适配商品 KVM/智能 PDU(JetKVM/NanoKVM/PiKVM 等),省下的精力投入判定/策略软件层 | +| 7 | 测试主机镜像/队伍管理缺章 | hibernate/resume 在真实 Windows 上有大量坑(快速启动、驱动、唤醒源、BitLocker、Windows Update 干扰)。需要 golden image + 组策略固化 + 主机健康自检方案——这本身就是护城河 know-how | +| 8 | Agent 自身可靠性 | "Agent 崩溃不丢证据"有验收,但没有"谁来救 Agent"的机制设计(本地看门狗、开机自启、双进程互监) | +| 9 | 法务与责任 | DUT 损坏责任条款(行业范式:破坏性测试知情免责 + 过错赔偿上限=合同额)、适配私有工具的 IP 约定、开源许可合规、POC 合同模板 | +| 10 | ROI 测量协议 | "人工执行一遍并记录"应升格为正式基线测量协议,写进 POC 合同,否则"显著减少人工"无法被客户财务认可 | +| 11 | 交付成本模型 | 每个 POC 人天预算、连接器复用率目标(>60%)——没有度量,"项目制陷阱"条款无法落地 | +| 12 | 竞品名单五处补遗 | 通用编排层(NI TestStand/OpenTAP/OpenHTF+TofuPilot)、量产设备商、中国本土(鸾起/置富)、测试服务商、商品 KVM。详见 §3 | + +**P2(产品化阶段补)** + +13. 开源策略未讨论(连接器 SDK/Agent 是否开源,各有利弊); +14. 多客户私有化部署矩阵——发布通道/LTS 政策要早定,否则升级成本爆炸; +15. UCR 可被刷(任务切小刷高),需定义"触碰"口径,以"每百小时介入次数"为主指标; +16. 文档应拆三份:内部战略版(现有)、客户 pitch 版、投资人版(加财务)。 + +--- + +## 2. 可行性分析 + +### 2.1 技术可行性:高(4/5) + +**结论:无科研级风险,全部是工程问题;真正的风险不是"能不能做出来",而是"在客户杂乱环境里稳不稳"。** + +- 所有组件均有成熟先例:商品 KVM/PDU 执行器 + 状态机 + 对象存储 + 采集管道。开源积木充足——labgrid 已解决电源/串口/资源互斥抽象(支持 30+ 种 PDU),Temporal 语义上完美匹配持久工作流(官方支持"分钟到数年"的执行)。 +- **真正难写对的三层**(也是壁垒所在): + 1. **判定层**:把"卡死"判对。误判一次 = 一台跑到第 60 小时的 3 天 retention 测试被无辜断电,比不自动化更糟。区分主机挂/盘挂/进程挂/网络抖,需要按机型、固件、测试类型积累的启发式阈值库——没有任何开源件自带; + 2. **物理状态与代码状态的对账**:durable engine 只保证代码状态持久,不保证物理状态一致(工作流认为在第 7 步,真实世界里主机已重启过、固件槽位已切换)。断电这类不可幂等动作的护栏 + 恢复对账,是全项目最难写对的代码; + 3. **取证顺序**:死机瞬间抓什么、按什么顺序抓(取证与快速恢复互相冲突)、怎么和固件版本/步骤对齐。 +- Windows 生态脏细节(GUI 工具、休眠唤醒、驱动、更新)是最大的工程摩擦源,必须用 golden image + 镜像管理对冲。 +- POC 3-4 个月时间盒内可验证。 + +### 2.2 市场可行性:中(3/5)——真实、在扩张、但是利基 + +**可数客户(自上而下)** + +| 客户类别 | 全球 | 中国大陆 | +|---|---|---| +| SSD 主控厂 | 独立主控 ~10 家 + 原厂自研 6 家 | 16-20 家(联芸、得一微、英韧、忆芯、华澜微、国科微、大普微、华存、兆芯、特纳飞……) | +| 企业级/DC SSD | 7 家原厂系 | 12-15 家有独立固件/验证团队(忆联、忆恒创源、大普微、得瑞、ScaleFlux、泽石、芯盛……) | +| 模组厂(有固件介入能力) | 全球有名有姓 SSD 制造商 235 家(多数无固件能力) | 30-50 家(江波龙、佰维、德明利、朗科、金泰克、七彩虹、嘉合劲威、宏旺、记忆科技……) | +| 工业/车规存储 | 25-35 家(Swissbit、宜鼎、宇瞻、ATP、Virtium……) | 若干家 + 大厂工规线 | +| Hyperscaler qual 团队 | 6-8 家海外 + 阿里/腾讯/字节/百度 | 不是首批客户(自研体系),但 OCP 规范是需求放大器:被验收方(SSD 厂)要买工具自证合规 | + +**中间层估算**(5-50 工位、有回归压力、无成熟自研平台):**中国约 30-40 家,全球约 60-90 家**;按每家 8-25 个可付费工位 → **SAM:中国 400-900 工位,全球 1,200-2,200 工位**。按 §2.3 定价折算,中国年软件盘子约 ¥1,000-3,000 万——**撑得起专精产品公司,撑不起典型 VC 故事**。上行空间:兼容性测试柜(方向 4)扩大单客户工位数、邻近垂直(服务器固件/BMC 测试、GPU 老化)、海外工业存储。 + +**痛点证据链(本次调研实证)** +- 联芸(猎聘 JD):"固件测试自动化开发、持续集成建设、失败用例分析定位"——这些岗位就是编排层缺失的人肉补丁; +- 江波龙 JD:"回归测试确保固件健康度";大普微 JD:"开发测试机台程序"(自建平台直接证据); +- 江波龙/佰维/记忆科技/晶存的测试装置专利全部强调"提高自动化程度/并行规模"——每家都在重复造执行层轮子,编排层仍靠人; +- 置富科技已用"一键启动、全程无人值守"营销测试柜——无人值守是被客户认可的卖点。 + +**行业周期**:2024 中国企业级 SSD 市场 $62.5 亿(+187.9%,IDC);2026Q1 企业级 SSD 价格环比 +80-100%,缺口预计到 2027 年底;江波龙 2025 营收 227.66 亿、佰维净利 +429%、德明利营收破百亿、大普微 IPO 推进中。**客户数量与测试工位都在增加,测试人手是瓶颈——窗口期成立。** + +### 2.3 商业可行性:中(3/5) + +**定价建议(有推理链)** +- 客户价值 = 省 0.5-1 个测试工程师(深圳/北京 SSD 测试工程师 15-25K×14 薪,企业年成本 ¥28-47 万)+ 夜间/周末设备利用率提升 30-50%(对 ¥300-1,200 万测试资产即 ¥18-60 万/年等效产能)→ 单客户年价值 ¥30-80 万; +- 定价取价值 20-35%:**软件 ¥6,000-12,000/工位/年**(30 工位以上阶梯降)+ **POC 交付服务一次性 ¥5-15 万** + **带外控制盒 ¥3,000-8,000/工位**; +- 典型 20 工位客户:首年合同 ¥25-40 万,续年 ¥15-25 万 ≈ 0.5-1 名工程师人力成本——ROI 一页纸讲得清;海外工业客户 $2,000-4,000/工位/年; +- **两个定价纪律**:绝不落进 $50/席位的 SaaS 锚点(TofuPilot 教训);年费必须绑定设备商不做的三样东西——跨厂商编排、客户 SOP 资产化、回归结果数据库——否则会被"硬件送软件"(Quarch 明示 API 无年费、SANBlaze 自带 OCP 脚本)替代; +- 行业公开锚点:PyNVMe3 软件 $59,000/年(不限部署)、NI 软件合同均值 ~$19k/年、DIY 三年 TCO >$500k(硅谷人力)/约 ¥150 万(国内人力)——"买 vs 造"话术的核心数字。 + +**销售周期与合同形态**:POC 1-3 个月 + 商务 1-6 个月,总周期按 3-9 个月规划;决策链 = 测试经理发起 → 固件总监批预算 → 安全审查私有化 → 采购走谈判/招标;准备"订阅"与"一年期服务合同"双合同形态(国企/上市公司报销体系)。 + +**生死度量**:客户现场 UCR 与每百小时介入次数(产品价值)、连接器复用率 >60%(产品公司 vs 外包公司分界)、每 POC 交付人天(服务毛利)。 + +### 2.4 团队可行性:条件分(落定前 2/5,落定后 3/5) + +- **最大的团队事实:合伙尚未落定。**「同学」目前只是意向对象,SOP、样品工位、客户引荐都还不是承诺。在其角色落定之前,本项目实际上是**单人项目**——单人做 to-B 硬科技(无领域背景、无客户通路)不成立,因此团队可行性在落定前按 2/5 看待,这是当前第一优先级和唯一阻塞项; +- 若落定,两人组合(产品/全栈/AI + 存储领域/客户/资金)是经典配置,覆盖 POC 阶段,恢复 3/5; +- 推进方式:用最小承诺测试逐级升级(见 §4 Phase 0 第 1 条),四周内走到"联合创始人 / 顾问 / 客户 champion"三选一,不允许停留在模糊状态。三种角色都能支撑项目启动,但对应不同的股权与替代招聘方案; +- **硬缺口**:全职验证工程师(Phase 1 可兼职借调,Phase 2 必须全职)、嵌入式工程师(改用商品 KVM 后需求后移,Phase 3 再考虑); +- 落定后仍要管理的结构性风险:既是渠道又可能是首客户,依赖与冲突并存——股权、竞业与客户关系归属要在书面协议里写清。 + +### 2.5 财务可行性(框架估算,按实际薪资修正) + +| 阶段 | 时间 | 团队 | 现金消耗(累计) | 里程碑 | +|---|---|---|---|---| +| Phase 0 排雷 | 第 1 个月 | 创始人 2 | ≈0 | Go/No-go 备忘录 | +| Phase 1 POC | 第 2-5 月 | 2 + 兼职 | 硬件 2-5 万;累计 30-60 万 | 7×24 无人值守 Demo | +| Phase 2 付费试点 | 第 5-9 月 | 4-5 人 | 累计 150-250 万 | 首笔收入(POC ¥5-15 万×1-2 单) | +| Phase 3 产品化 | 第 10-18 月 | 5-8 人 | 累计 400-600 万 | 3-5 客户、ARR ¥100-200 万 | + +融资建议:**天使/产业资本 300-500 万**(代理商、模组厂 CVC、地方产业基金都比纯财务 VC 匹配),或自有资金 + POC 收入滚动。叙事对标存在但要诚实:硬件测试数据平台 Nominal 2026 年 3 月达 $10 亿估值(10 个月融 $155M)、AI SRE 公司 Resolve.ai 估值 $15 亿——资本已验证"硬件测试"与"AI 根因调查"两个故事,而**"控制平面"(把机器救活继续跑)无人占位**;但你的可数市场决定了讲法应是"利基里的绝对领先 + 邻近垂直扩张",不是平台故事。 + +### 2.6 总体结论 + +**有条件立项。** 前置条件与 Kill Criteria 见执行摘要。最大的三个风险按序:**领域合伙人未落定(当前唯一的阻塞项,条件 0)** → 市场厚度(中间层客户数量有限)→ 项目制泥潭(连接器复用率)。技术不在前三。 + +--- + +## 3. 竞品调研(四组联网调研合并) + +### 3.1 五家直接竞品:逐项核实结果 + +**五项差异化主张 vs 竞品现状**(公开资料核实,2026-07) + +| 能力 | ULINK | OakGate | SANBlaze | Quarch | PyNVMe3 | 判定 | +|---|---|---|---|---|---|---| +| 测试主机卡死自恢复 | 无 | 架构性缓解¹ | 架构性缓解¹ | 有零件无闭环² | 无 | **真空白** | +| 跨工具统一证据包 | 无 | 单生态强 | 单生态 | 功耗+边带同轴 | 单生态 | **真空白** | +| 失败聚类去重 | 未见 | 未见 | 未见 | 无 | 未见 | **全行业无人交付** | +| 断点续跑 | 未见 | 无 checkpoint | 未见 | 无 | pytest 语义,无 | **真空白** | +| AI 辅助判障 | 无³ | 无 | 无 | 无 | 无⁴ | **最干净的差异化** | + +¹ 专用 Linux appliance 即测试主机,环境受控、客户端分离——痛点被架构降低而非功能解决;² Quarch 模块走独立 LAN/USB 通道,是自救的现成执行器,但不提供"检测→恢复→续跑"编排;³ ULINK 的 AI(DA SmartQuest)面向在役硬盘故障预测,与实验室无关;⁴ PyNVMe3 的"LLM workload"是用 AI 负载测盘,不是 AI 分析失败。 + +**各家快照与威胁评级** + +| 竞品 | 近况(2025-2026) | 威胁 | 合作 | 策略 | +|---|---|---|---|---| +| ULINK DriveMaster | DM10 v10.1.1900、PSPA+ Gen5、无 Gen6 路线、无公开 API、Windows GUI 封闭 | 2/5 | 1/5 | TCG 认证细分互补;GUI 封闭反而难集成 | +| Teledyne LeCroy OakGate | PCIe 6.0 方案 2025-08 可订购;REST/Python/CLI/C API 完整;Automation Tool 单生态编排 | 4/5 | 2/5 | 视为高价值执行器,用其 API 接入 | +| SANBlaze | SBExpress-RM6 业界首个 Gen6 系统(2025-10);OCP 2.6 + Azure 深度绑定;REST/XML/Python | 4/5 | 3/5 | 同上;其 OCP 地位是"被验收方买工具"逻辑的最好注脚 | +| Quarch | Gen6 全家桶 + Aon 桌面一体机;quarchpy 全线 Python;被 SerialTek/PyNVMe3 集成的先例 | 1/5 | **5/5** | **天然盟友,尽早建集成/转售关系** | +| PyNVMe3 / GYTech | $59k/年不限部署 + F6 12 盘服务器 $39k(公开价);已建实验室层雏形(槽位编排、Grafana 监控);中国主控/原厂渗透深 | **4/5(对中国 5/5)** | 3/5 | **最大战略威胁**:同构(Python/CI/年费/软件优先),向上一步就是编排层。既是最优先集成对象,也是最可能相撞者;注意其 EULA 禁止用于开发竞品,商务需切割 | + +关键修正:**最需警惕的不是"谁已经做了",而是"谁最容易补上"**——OakGate/SANBlaze 给自家 appliance 加 watchdog 的工程成本不高,但商业惯性(卖盒子、单生态)使其大概率不做跨厂商编排。LabOS 应死守的定位:**异构工具之上的中立层**。 + +### 3.2 V1.0 完全遗漏的五类竞品 + +**① 通用测试编排层**(能力不重叠,但销售时会被拿来比价) +- **NI TestStand + SystemLink**(现属 Emerson):行业标准测试序列器;"单站、有人值守、产线节拍"范式,无主机自救概念。价格参考:NI 软件合同 $3k-40k/年(均值 ~$19k); +- **Keysight OpenTAP**:开源序列引擎,生态以自家射频仪器为中心,无存储案例——证明"水平化测试编排平台长不出垂直深度",反向支持做垂直; +- **OpenHTF(Google 开源)+ TofuPilot(瑞士,2022)**:画像最像的创业公司——OSS 楔子 + SaaS 分析层,$50/用户/月 + $50/站/月。但做产线短测试(分钟级、良率视角),不做编排/恢复/取证。它证明了模式,也划出了**必须避开的低价锚点**; +- **Nominal**($1B 估值,Founders Fund/Sequoia,10 个月融 $155M)与 **Sift**(ex-SpaceX,$42M B 轮):硬件测试数据平台已被资本验证——但全部是**数据平面**(遥测进、分析出),**控制平面无人占位**。这是融资叙事的现成对标,也是远期威胁(Nominal Connect 已做边缘测试执行,切入存储只缺动机); +- Averna / Marvin ATEasy:测试集成商——"大厂不自研时会雇的人",应加入竞品清单。 + +**② 量产/可靠性测试设备商**(离研发回归很远,但要主动切割叙事) +- Neosem(Gen5 测试机,供三星/美光/英特尔;中国独家代理=深圳锐伺)、EXICON(三星老化机主力,单批订单 1.65 亿元人民币)、Advantest MPT3000(SLT,百万级整机)、Teradyne(芯片级 ATE + HDD 产线)、Chroma(已淡出 SSD 测试); +- **"存储测试国产化"的资本叙事(精智达 ~13 亿订单、悦芯 12 亿融资、武汉精鸿长存中标)全部在芯片级 ATE**——与盘级固件回归是两个市场,给投资人讲故事必须主动切割,否则被错误对标。 + +**③ 中国本土两家必须写进方案的公司** +- **鸾起科技**(苏州,2022;创始人岑彪 ex-AMD/LSI/CNEX,《深入浅出 SSD》作者之一;2026-01 中科创星领投 B 轮近亿元):"国产 SANBlaze"生态位——eBird 研发端测试平台(Gen5,协议级到系统级,Turnkey 用例包)+ Phoenix 量产 RDT。忆恒创源已导入,与联想/长江存储有合作。**公开资料未见实验室编排产品**——它既是最可能向编排层延伸的本土竞对,也是最合理的集成/渠道伙伴(eBird 可作被编排执行器); +- **置富科技**(深圳,北交所):2025-04 发布五款宽温智能测试设备(128-256 盘位),营销话术就是"开放式工单配置平台 + 一键启动、全程无人值守"——**"无人值守"心智已被抢注**,但其软件绑定自家柜体,反而验证了"硬件无关的编排软件"差异化成立。最需盯防的邻位竞对。 + +**④ 第三方测试服务商 = 互补而非替代** +- 百佳泰 Allion(SSD 顾问/Workload 采集/竞品对标)、GRL(PCIe 官方合规认证)、宜特 iST(芯片可靠性)、UNH-IOL(NVMe 合规权威,SANBlaze 是其执行平台)、赛宝/中析(送样检测,用的正是 DriveMaster); +- 外包适合低频里程碑事件(认证/对标/摸底);高频固件回归外发有**保密 + 周转**两大硬伤。真正的替代品是"多招两个测试工程师值夜班 + 自写脚本"。 + +**⑤ 商品 KVM / OOB 硬件浪潮**(对 V1.0 硬件方案的直接修正) + +| 产品 | 价格 | ATX 控制 | 可编程性 | +|---|---|---|---| +| JetKVM | $69(众筹 $400 万+/3 万人) | 官方 ATX 扩展板 | Go 后端开源 | +| Sipeed NanoKVM / Pro | $20 起 | 有 ATX 版 | 开源;Pro 已带**多节点批量管理 + AI 助手** | +| GL.iNet Comet | PoE 版 $109.99;Comet X 一拖四 | 官方 ATX 板 | 集中管理 | +| PiKVM | 数百美元级 | 官方 ATX 板 | HTTP API、**IPMI & Redfish**、Prometheus、宏 | + +服务器场景 OOB 本是标配(IPMI/BMC、Redfish、Intel AMT、智能 PDU)。**结论:硬件本身零差异化;商品盒子没有且短期不会有的是四层软件**——心跳判定语义、分级恢复策略引擎(升级/退避/冷却/熔断 + 恢复前先取证)、与工作流状态联动、证据留存。警惕商品盒子从下往上长(NanoKVM Pro 已加批量管理和 AI 助手)。 + +### 3.3 DIY 威胁的定量刻画(最重要的竞争事实) + +> **一个 2-3 人的内部团队,3-6 个月能拼出"能演示的 80%";拼不出"连续 90 天无人值守的 80%"。产品的全部价值在后者。** + +- 拼得出(这就是每家 SSD 厂都有 Jenkins 脚本坟场的原因——DIY 不是假想敌,是已发生的现实):商品 KVM + 智能 PDU + labgrid(1-2 周)→ Temporal 包 SOP(1-2 月)→ pytest + 正则聚类 + 模板报告(再 1-2 月); +- 拼不出的 20%(DIY 团队在第 6-18 个月撞上): + 1. 判定层误判致死(一次误断电毁掉跑到第 60 小时的 retention 测试)——靠 incident 数量堆出来的阈值库; + 2. 物理状态与代码状态的对账 + 不可幂等动作护栏——全项目最难写对的代码; + 3. Evidence Bundle 的领域深度(抓什么、什么顺序、怎么对齐); + 4. 失败聚类冷启动(算法几周,可信聚类需几个季度的带标注语料); + 5. 机群运维长尾(USB 重枚举、EDID、串口猝死——Linaro/Pengutronix 为此养全职团队); +- DIY 真实 TCO:2-3 人×6 月建设 + 永久 ~1 FTE 维护 + 关键人一走就腐烂;三年 TCO 国内人力约 ¥150 万、硅谷 >$500k——**这是定价天花板依据和"买 vs 造"销售话术的核心数字**。 + +### 3.4 可防御空间排序(护城河修正版) + +1. **机型级判定/恢复知识库**——唯一随时间复利、DIY 团队永远只有单厂样本的资产; +2. **Evidence Bundle schema 与取证顺序**做成事实标准(对标 OCP telemetry 生态,配合"报告可直接交给下游客户审计"叙事); +3. **每客户私有失败语料 + 共享解析器/模型架构**(行业保密性强,跨客户共享语料几乎不可能——V1.0 §9.2 数据飞轮的脱敏设计是对的,但护城河强度要打七折); +4. **"无人值守"作为 SLA 卖**——买的是夜班消失,不是软件席位; +5. **不可防御、不要投入**:OOB 硬件、KVM 软件、工作流引擎、测试框架、通用聚类算法——全部用现成的。 + +### 3.5 窗口期判断 + +三面夹击的时间表:上有 Nominal($155M 弹药)/TofuPilot 下探,下有 NanoKVM Pro/PiKVM 往批量管理和 AI 助手上长,侧有 PyNVMe3/鸾起/置富从执行层向编排爬。**窗口真实存在,但按 18-24 个月规划,不按三年规划**——这也是 Phase 1/2 必须严格时间盒的原因。 + +--- + +## 4. 总体规划(重排路线图) + +核心修正:V1.0 阶段 A-E 只有内容没有时间和钱。以下为带时间盒、预算、验收和 Kill 条件的版本。 + +### Phase 0 — 合伙落定、客户发现与排雷(第 1-4 周,现金 ≈0) + +写代码之前完成(第 1 条是其余一切的前提): +1. **谈定领域合伙人(真正的第 0 步——目前"同学"尚未承诺任何事)**。用升级阶梯推进,避免一上来就要"辞职入伙"这种大到只能拖延的请求: + - ① 最小承诺测试(本周,对方成本≈0):一套脱敏 SOP 或口头走一遍流程 + 30 分钟值守现状访谈。可以拿本报告当谈判工具——竞品与市场数据是现成的"这事值得干"论证; + - ② 第二级(第 2-4 周):2-3 个访谈引荐 + 联合做一次人工基线测量; + - ③ 第三级(POC 启动前):角色三选一并书面化——联合创始人(股权 + 时间承诺)/ 顾问(小比例顾问股 + 明确交付清单)/ 首客户 champion(不占股,客户关系)。三种都能支撑启动,但必须选一个; + - **决断时限**:四周内走不到第②级,即启动替代路径——付费聘请在职/离职 SSD 验证工程师做顾问(§2.2 的 JD 调研证明这个人群在深圳/上海可及),并经 ssdfans 社区、CFMS 人脉、森弗/锐测代理商冷启动接触 2-3 家中型厂谈 design partner; +2. ≥3 家公司、5-10 人分层访谈(用 V1.0 §11.5 清单:固件工程师/验证工程师/实验室负责人); +3. 拿到 ≥2 套真实 SOP(合伙人来源 + ≥1 外部); +4. 人工全程执行一遍 SOP,录屏计时 → ROI 基线测量协议(未来 POC 合同附件); +5. 首客户工具链清点:每个工具标 CLI/API/GUI-only → GUI 风险预案; +6. 新名字 + 商标检索(9/42 类 + 美国); +7. 向森弗/锐测询价补齐 SANBlaze/ULINK 在华价格锚点。 + +产出:Go/No-go 备忘录 + 方案 V1.1。 +Kill:领域合伙人与替代路径双双落空;无人愿给 SOP;或"值守/复现"痛点排不进前三。 + +### Phase 1 — 单工位 POC(第 2-5 月,3.5 个月时间盒,硬件预算 2-5 万) + +Phase 1 拆为 1a/1b 两段,依赖不同: + +**1a 技术样机(可提前与 Phase 0 并行;2-4 周时间盒;零售/二手盘;无任何外部依赖)** +- 验证四层差异化能力:心跳判定、分级恢复、断点续跑、证据采集——这四层与盘上跑什么固件无关(蓝屏就是蓝屏); +- 刷写机制用 nvme-cli 标准通道排练:对同一固件镜像反复 download/commit/激活即可跑通全部机制,A/B 对比引擎用"任意两个变体"(两块盘/两套配置)先行验证; +- DUT 来源:零售或闲鱼二手盘(¥200-800/块,刷坏不心疼);选型前实测目标型号的 fw-commit 行为与降级策略;国产 SMI/联芸主控盘的开卡工具生态可作内部台架的补充手段(灰色来源,仅限内部开发,不得用于对外演示); +- 产出:一台"会自救的演示台"——同时是合伙人与客户谈判的最强道具; +- 纪律:时间盒必须硬。造东西是舒适区,访谈是不适区;1a 结束时若合伙谈判与访谈无进展,该停就停。 + +**1b 真实 SOP 落地(被前置条件 0 门控:需要真实样品、固件 A/B 与厂商刷写工具)** +- 团队:创始人 2 + 兼职验证工程师; +- 硬件:x86 测试主机 + 可牺牲 DUT 若干 + **商品 KVM(JetKVM/PiKVM)+ 智能 PDU** + 温度传感 + UPS(树莓派方案降级为兼容选项); +- 范围(相对 V1.0 MVP 再收缩):OS 按首客户 SOP 二选一;Web 控制台降级为 CLI + 静态 HTML 报告;AI 只做日志摘要 + 报告草稿。全部工程量押在**状态机 / 分级恢复 / 证据采集**三件事上; +- 硬验收(在 V1.0 §4.5 基础上加硬): + - 连续 7×24 无人值守运行不崩; + - 100 循环 A/B 完整跑通 ≥2 次; + - 三类注入故障(杀 Agent、蓝屏/panic、拔网线)自动恢复成功率 ≥80%; + - 误盘 = 0;失败发生 → 证据包可下载 <5 分钟。 +- Kill:8 周内无法稳定完成 20 循环;或恢复成功率 <60%。 + +### Phase 2 — 首个付费试点(第 5-9 月,团队 4-5 人,累计现金 150-250 万) + +- 商业:1-2 家付费 POC。两种打法:A. 付费 POC ¥8-20 万/单(含连接器开发)验收转订阅;B. 免费 POC + 验收付费 ¥30-50 万转订阅(标杆客户); +- 产品化:安装部署包、角色权限、审计、正式控制台、连接器 SDK v0、缺陷系统对接(Jira/禅道); +- 客户画像(按调研修正):珠三角/长三角的中型主控厂(得一微/忆芯/华存/兆芯/特纳飞档)、企业级新锐(大普微/得瑞/泽石/芯盛档)、头部模组厂固件测试部(江波龙/佰维/德明利档);10-30 工位、已有 SANBlaze/Quarch/温箱资产者优先; +- 渠道:与森弗/锐测谈联合方案或转售分成(它们有全部目标客户关系,主业卖硬件,与编排层互补;风险:设备商自家软件向上长时渠道会站队); +- 现场指标:客户现场 UCR ≥90%;"每周释放工时"报告由客户签字确认(销售武器); +- 门槛:首笔外部收入 + 第二套 SOP 连接器复用率 ≥50%。 + +### Phase 3 — 产品化与多工位(第 10-18 月,累计现金 400-600 万) + +- 3-5 家客户、20+ 工位、2-3 份年订阅合同(目标 ARR ¥100-200 万); +- 多工位调度、资产/许可证管理、跨工位失败聚类(= V1.0 阶段 C 裁剪);发布通道/LTS 政策定版; +- 硬件工程样机外包设计(若客户确有需求);失败指纹库跨客户沉淀(脱敏规则同步、客户授权制); +- GTM:CFMS|MemoryS 2027(3 月·深圳)正式亮相;FMS 2027 看海外工业存储线索; +- 决策点:产业资本融资扩张 vs 利润自养。 + +### Phase 4 — 决策门触发的扩张(18 月+) + +兼容性循环测试柜(方向 4)、RMA 初检、来料质检、运营分析——维持 V1.0 §10.6 决策门设计,不排日历。新增一条远期选项:**向邻近垂直扩**(服务器 BMC/固件测试、GPU 老化、存储系统),这是突破利基天花板的主路径。 + +### 对外叙事 + +- 不讲"AI 测试 SSD",讲**"存储实验室的无人值守执行层 / 控制平面"**; +- 对投资人:硬件测试数据平面已出独角兽(Nominal $1B)、AI 根因调查已被验证(Resolve.ai $1.5B)——**控制平面无人占位**,我们从存储垂直切入;同时诚实给出利基市场数字与邻近垂直扩张路径; +- 对客户:固件回归不再需要工程师通宵值守(V1.0 原话保留,很好);ROI 一页纸 = 续年费 ≈ 0.5-1 名工程师年成本。 + +--- + +## 5. 数据缺口与两周补齐计划 + +| 缺口 | 补齐方法 | +|---|---| +| SANBlaze/ULINK/OakGate 在华成交价 | 以采购名义向森弗(上海)/锐测(深圳)询价;或开千里马/采招网付费账号查历史中标 | +| "值守/夜班"一手抱怨文本 | 人工登录 BOSS 直聘/脉脉检索"SSD 测试 值班/夜班/两班倒";访谈中直接问 | +| 美国商标明细 | USPTO TESS 人工检索新名字 | +| 客户支付意愿实证 | Phase 0 的 10 个访谈中放入定价卡片测试(¥6k/¥9k/¥12k 每工位三档) | +| PyNVMe3 实际部署形态 | 若访谈对象中有其客户,追问其值守/断点/取证痛点(验证互补空间) | + +--- + +## 附录 A:主要信息来源(节选) + +竞品官网与文档:ulinktech.com;teledynelecroy.com/ssdtesting;sanblaze.com(rest-interface / python-docs);quarch.com;pynv.me(pricing / EULA);github.com/pynvme/pynvme;serialtek.com;viavisolutions.com;labgrid.readthedocs.io;docs.lavasoftware.org;opentap.io;openhtf.com;tofupilot.com/pricing;nominal.io;siftstack.com;jetkvm.com;docs.pikvm.org;gl-inet.com;docs.temporal.io。 +行业与市场:StorageNewsletter(OakGate/SANBlaze Gen6、GYTech、SSD 制造商清单);futurememorystorage.com(FMS 2026);chinaflashmarket.com(CFM/MemoryS);IDC 中国企业级 SSD 数据(转引);东方财富/雪球/新浪财经(江波龙/佰维/德明利/大普微业绩)。 +中国本土:投中网/上证报(鸾起科技);爱集微(置富科技);realsys.com.cn(锐伺/Neosem);saniffer.com;reetest.com;0101semi.com(价格锚点);猎聘/BOSS 直聘 JD(联芸/江波龙/大普微/忆恒创源);国家专利(江波龙 CN121922187A、佰维 CN223167249U)。 +命名:labos.co;阿里云商标库(LABOS 第 9 类)。 + +*调研执行说明:部分搜索引擎与招标平台存在反爬/付费墙限制,凡未能取得一手数据处均在正文标注"未查到"并给出替代估算方法;重要结论均有至少两个独立来源交叉验证。竞品"缺口"结论基于公开资料,不排除存在未公开功能,建议关键项通过评估机/demo 实测复核。* + +--- + +*本报告由外部顾问基于方案 V1.0 与 2026-07-27 联网调研撰写,供两位创始人决策参考。建议以本报告 §1.2 缺口清单和 §4 路线图为基础修订出方案 V1.1。* diff --git a/flashops/.gitignore b/flashops/.gitignore new file mode 100644 index 0000000..0bc05b6 --- /dev/null +++ b/flashops/.gitignore @@ -0,0 +1,26 @@ +# Python +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +*.egg-info/ + +# Node +node_modules/ +.next/ +out/ +.turbo/ + +# 本地运行产物(开发态的 SQLite 库、对象存储、日志) +var/ +*.db +*.db-journal +*.log + +# 环境 +.env +.env.local + +# macOS +.DS_Store diff --git a/flashops/Makefile b/flashops/Makefile new file mode 100644 index 0000000..a781d6d --- /dev/null +++ b/flashops/Makefile @@ -0,0 +1,74 @@ +.PHONY: help setup setup-py setup-web db-reset seed demo dev dev-api dev-web dev-agent dev-agent-loop test test-py e2e clean + +SHELL := /bin/bash +ROOT := $(CURDIR) +PY := "$(ROOT)/.venv/bin/python" +PIP := "$(ROOT)/.venv/bin/pip" +CP := $(ROOT)/services/control-plane +AGENT := $(ROOT)/services/host-agent +CONSOLE := $(ROOT)/../前端UI八页面完成 +export PYTHONPATH := $(CP):$(AGENT) + +help: + @echo "FlashOps —— 常用命令" + @echo "" + @echo " make setup 建 venv、装依赖、建库、灌种子数据" + @echo " make demo 无硬件跑通一整条固件 A/B 回归(含故障注入与恢复)" + @echo " make dev 起控制平面与同源控制台 :8000" + @echo " make dev-agent 向已启动的控制平面注册并发送一次真实 Agent 心跳" + @echo " make test 后端单测 + 端到端 smoke" + @echo " make db-reset 删库重建并重灌种子" + @echo " make clean 清理本地运行产物" + +setup: setup-py db-reset + @echo "" + @echo "✅ 就绪。下一步:make demo(看它跑)或 make dev(起服务)" + +setup-py: + @test -d .venv || python3 -m venv .venv + @$(PIP) install --quiet --upgrade pip + @$(PIP) install --quiet -r services/control-plane/requirements.txt + @echo "✅ 后端依赖就绪" + +db-reset: + @rm -f "$(ROOT)/var/flashops.db" + @rm -rf "$(ROOT)/var/objects" + @mkdir -p "$(ROOT)/var/objects" + @$(PY) -m flashops_control.cli init-db + @$(PY) -m flashops_control.cli seed + @echo "✅ 数据库已重建并灌入种子数据" + +seed: + @$(PY) -m flashops_control.cli seed + +demo: + @$(PY) -m flashops_control.cli demo + +dev: + @echo "控制台 http://localhost:8000/console/Dashboard.dc.html · API http://localhost:8000/docs" + @$(PY) -m uvicorn flashops_control.main:app --host 0.0.0.0 --port 8000 --reload + +dev-api: + @$(PY) -m uvicorn flashops_control.main:app --host 0.0.0.0 --port 8000 --reload + +dev-web: + @cd "$(CONSOLE)" && "$(PY)" -m http.server 3000 + +dev-agent: + @$(PY) -m flashops_agent --server http://127.0.0.1:8000 --once + +dev-agent-loop: + @$(PY) -m flashops_agent --server http://127.0.0.1:8000 + +test: test-py + +test-py: + @"$(ROOT)/.venv/bin/pytest" services/control-plane/tests services/host-agent/tests -q + +e2e: + @"$(ROOT)/.venv/bin/pytest" services/control-plane/tests/test_e2e_vertical_slice.py -q -s + +clean: + @rm -rf "$(ROOT)/var" "$(ROOT)/.pytest_cache" + @find "$(ROOT)" -name __pycache__ -type d -prune -exec rm -rf {} + + @echo "✅ 已清理" diff --git a/flashops/README.md b/flashops/README.md new file mode 100644 index 0000000..8bbd120 --- /dev/null +++ b/flashops/README.md @@ -0,0 +1,103 @@ +# FlashOps — 存储实验室无人值守执行层 + +> 固件回归测试的**控制平面**:把一条真实 SOP 变成可执行、可恢复、可取证的状态机, +> 让工程师不必通宵值守。 + +**命名是临时的。** `FlashOps` 只是后端代号,控制台目前仍显示 `STORAGE LABOS`; +商标与最终品牌确认后,需要统一后端常量、控制台标题和文档。 + +--- + +## 当前可运行基线 + +现在已有一条**无需硬件即可端到端跑通**的真实纵向链路: + +``` +建任务 → 六项安全预检 → 资源加锁 → N 次模拟循环 + → 注入 Agent 心跳丢失 → L1/L2 失败 → L3 带外 Reset 恢复 + → 从循环检查点续跑 → Evidence Bundle 打包 → 前端实时可见 +``` + +全程使用内置模拟 DUT、测试主机与带外控制器,一条命令即可完成。当前实现重点是 +事件溯源写路径、安全门禁、状态转移、恢复记录和可复现演示;独立 Host Agent +已经完成注册、token 与心跳最小链路,但步骤租约、真实硬件适配器、失败聚类执行逻辑 +与完整权限体系仍是后续工作,不在此基线中伪装完成。 + +--- + +## 快速开始 + +前置:Python 3.9+。**不需要 Node 或 Docker。** + +```bash +make setup # 建 venv、装依赖、建库、灌种子数据 +make demo # 跑一条 8 轮模拟回归(含故障注入、恢复与证据包) +make dev # 起控制平面与控制台 :8000 +make dev-agent # 注册独立 Host Agent 并发送一次心跳 +make test # 控制平面 + Agent + API 端到端测试 +``` + +`make demo` 结束会打印 Run ID 与 Evidence Bundle 路径;`make dev` 后打开 +,运行预检并创建任务,再进入“实时运行” +即可看到同一条 Run 的事件时间线。API 文档在 。 + +--- + +## 仓库结构 + +``` +flashops/ +├─ docs/ 架构与规范(先读这里) +│ ├─ architecture.md 五层架构 → 代码目录的逐层映射 +│ ├─ domain-model.md 7 个核心领域对象 → 数据库表 +│ ├─ state-machine.md Run/Step 状态机、恢复阶梯、检查点语义 +│ ├─ adapter-sdk.md 工具适配器契约(产品扩张的关键边界) +│ ├─ workflow-spec.md SOP → YAML 工作流的 schema +│ └─ decisions/ ADR:为什么这么选,以及什么时候该推翻 +├─ services/ +│ ├─ control-plane/ FastAPI 控制平面(事件、安全、模拟执行、证据) +│ └─ host-agent/ 独立拉模式 Agent(注册、凭据、心跳) +├─ ../前端UI八页面完成/ 控制台原型;由 FastAPI 挂载到 /console +├─ docker-compose.yml 目标生产拓扑草案(尚未作为发布物验收) +└─ Makefile +``` + +--- + +## 三条必须守住的架构红线 + +写后续代码时,这三条一旦破了,返工成本最高: + +1. **控制面与数据面分离**——任务状态的唯一真相在控制平面数据库,测试主机本地只有缓存。 + Agent 挂了、主机重装了,Run 状态不丢。 + +2. **事件溯源**——所有状态变化以 append-only 事件写入 `run_events`, + 当前状态是事件的投影而非独立真相。时间线、审计、复现全部由此派生。 + *禁止*绕过 `events/recorder.py` 直接 UPDATE Run 状态。 + +3. **安全默认拒绝**——当前序列号、破坏性授权、系统盘、固件型号、Host 与 OOB + 任一不满足即在 `safety/` 层拒绝。真实 Agent 接入前还必须补签名命令模板, + 保证 Agent 只执行控制平面批准的动作。 + +--- + +## 当前状态与下一步 + +| 能力 | 状态 | 说明 | +|---|---|---| +| 14 张持久化表 | ✅ 已落地 | SQLite 建表与种子数据可重复执行 | +| Run 事件溯源 | ✅ 已落地 | 状态只能经 `events/recorder.py` 投影 | +| Run 状态转移 | ✅ 已落地 | 纯函数转移表,终态不可恢复 | +| 六项安全门禁 | ✅ 已落地 | 序列号、破坏性授权、系统盘、固件、Host、OOB | +| L1–L5 恢复决策 | ✅ 已落地 | 纯函数;演示链路实际执行 L1–L3 | +| 循环检查点续跑 | ✅ 模拟落地 | 循环边界保存,恢复后续跑 | +| Evidence Bundle | ✅ 最小实现 | `manifest.json` + `events.ndjson`,完整率 100% | +| 控制台 | 🟨 部分接入 | 任务中心与实时运行接 API;其余页面仍为静态样例 | +| 独立 Host Agent | 🟨 部分落地 | 注册、token 摘要、心跳、主机探针与 15s/30s 状态老化已实现;Step 租约待实现 | +| 真实 nvme-cli 与 OOB | ⬜ 待实现 | 需要可牺牲 DUT 与 JetKVM/PDU | +| 失败签名执行与聚类 | ⬜ 待实现 | 数据表已定义,服务逻辑未接入 | +| 多工位调度、认证与权限 | ⬜ 待实现 | 进入真实实验室前必须补齐 | + +下一步建议实现 Step 租约、ACK、续租、完成上报与幂等事件,再把内置模拟执行器迁到 +独立 Agent;完成这层后再接真实 `nvme-cli` 与 JetKVM,才能保证硬件接入不绕过 +控制面安全规则。 diff --git a/flashops/deploy/README.md b/flashops/deploy/README.md new file mode 100644 index 0000000..6b182cc --- /dev/null +++ b/flashops/deploy/README.md @@ -0,0 +1,35 @@ +# FlashOps production deployment + +- Domain: `flashops.imagebrewing.com` +- App root: `/data/wangzhan/app/storage-labos` +- Backend bind: `127.0.0.1:18080` +- Process manager: `systemd` unit `flashops.service` +- Reverse proxy: Baota Nginx vhost `flashops.imagebrewing.com.conf` +- Persistent data: `/data/wangzhan/app/storage-labos/flashops/var` +- Private environment file: `/etc/flashops/flashops.env` + +The service deliberately uses one worker because the current production store is +SQLite. The current preview console is anonymously readable and writable through +Nginx; do not connect real hardware or production data until application RBAC and +approval gates are implemented. The ACME challenge path remains available so +certificate renewal can complete. + +Before enabling Agent enrollment, create the environment file without putting +the secret in the repository or unit: + +```bash +sudo install -d -o root -g root -m 0755 /etc/flashops +sudo install -o root -g root -m 0600 /dev/null /etc/flashops/flashops.env +sudoedit /etc/flashops/flashops.env +``` + +Add `FLASHOPS_AGENT_ENROLLMENT_TOKEN=`, then restart the service. + +Useful checks: + +```bash +sudo systemctl status flashops +curl http://127.0.0.1:18080/api/v1/health +sudo nginx -t +sudo certbot renew --dry-run +``` diff --git a/flashops/deploy/certbot-reload-nginx.sh b/flashops/deploy/certbot-reload-nginx.sh new file mode 100644 index 0000000..a5a5e40 --- /dev/null +++ b/flashops/deploy/certbot-reload-nginx.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +/usr/bin/systemctl reload nginx diff --git a/flashops/deploy/flashops.service b/flashops/deploy/flashops.service new file mode 100644 index 0000000..512ca52 --- /dev/null +++ b/flashops/deploy/flashops.service @@ -0,0 +1,31 @@ +[Unit] +Description=FlashOps control plane +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ubuntu +Group=ubuntu +WorkingDirectory=/data/wangzhan/app/storage-labos/flashops +EnvironmentFile=-/etc/flashops/flashops.env +Environment=PYTHONPATH=/data/wangzhan/app/storage-labos/flashops/services/control-plane:/data/wangzhan/app/storage-labos/flashops/services/host-agent +Environment=FLASHOPS_ENV=production +Environment=FLASHOPS_API_HOST=127.0.0.1 +Environment=FLASHOPS_API_PORT=18080 +Environment=FLASHOPS_DATABASE_URL=sqlite+aiosqlite:////data/wangzhan/app/storage-labos/flashops/var/flashops.db +Environment=FLASHOPS_OBJECT_STORE_URL=/data/wangzhan/app/storage-labos/flashops/var/objects +Environment=FLASHOPS_CORS_ORIGINS=https://flashops.imagebrewing.com +ExecStart=/data/wangzhan/app/storage-labos/flashops/.venv/bin/uvicorn flashops_control.main:app --host 127.0.0.1 --port 18080 --workers 1 --proxy-headers --forwarded-allow-ips=127.0.0.1 +Restart=on-failure +RestartSec=3 +TimeoutStopSec=15 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=/data/wangzhan/app/storage-labos/flashops/var +UMask=0027 + +[Install] +WantedBy=multi-user.target diff --git a/flashops/deploy/gitea/README.md b/flashops/deploy/gitea/README.md new file mode 100644 index 0000000..51dfefb --- /dev/null +++ b/flashops/deploy/gitea/README.md @@ -0,0 +1,41 @@ +# Gitea deployment + +The team Git service runs as Gitea 1.27.0 on the existing CVM: + +- public URL: `https://git.imagebrewing.com`; +- container HTTP bind: `127.0.0.1:13000`; +- persistent data: `/data/gitea/data`; +- database: Gitea-managed SQLite; +- Git transport: HTTPS only; no extra public SSH port; +- anonymous users may browse public repositories; +- account registration requires administrator approval before sign-in. +- daily backup target: `/chucun/wangzhan-production/backups/gitea/`. + +The service is intentionally separate from the FlashOps runtime and database. +Back up `/data/gitea/data` before an image upgrade. Pin the image version and +review the Gitea release notes before changing it. + +The current server does not have the Docker Compose plugin. The checked-in +`docker-compose.yml` is the declarative service record for future rebuilds; +the running container was created with the equivalent pinned `docker run` +settings. Routine checks and restarts therefore use Docker directly: + +```bash +sudo docker ps --filter name=^gitea$ +sudo docker restart gitea +sudo docker logs --tail 100 gitea +curl -fsS http://127.0.0.1:13000/api/healthz +``` + +Install Compose or recreate the container from the checked-in definition only +during a planned maintenance window, after a verified backup. + +Nginx terminates HTTPS and proxies to the loopback-only container port. Run +`nginx -t` before every reload. Credentials and API tokens are never stored in +this directory or committed to Git. + +`gitea-backup.timer` runs daily at 03:20 Asia/Shanghai with a randomized delay. +It uses Gitea's own dump command, writes the ZIP and SHA-256 sidecar to the +mounted COS bucket, and removes the temporary local dump after a successful copy. +The live repository and SQLite database remain on the local filesystem because +COSFS is a backup destination, not a POSIX database filesystem. diff --git a/flashops/deploy/gitea/backup.sh b/flashops/deploy/gitea/backup.sh new file mode 100644 index 0000000..d96d20c --- /dev/null +++ b/flashops/deploy/gitea/backup.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +backup_dir=/chucun/wangzhan-production/backups/gitea +container_dump=/data/gitea-backup.zip +host_dump=/data/gitea/data/gitea-backup.zip +stamp=$(date -u +%Y%m%dT%H%M%SZ) +target="$backup_dir/gitea-$stamp.zip" + +install -d -o ubuntu -g ubuntu -m 0770 "$backup_dir" +if [ -e "$host_dump" ]; then + unlink "$host_dump" +fi + +docker exec -u git gitea sh -c \ + 'mkdir -p /tmp/gitea-dump && find /tmp/gitea-dump -mindepth 1 -delete' +docker exec -u git gitea gitea dump \ + --config /data/gitea/conf/app.ini \ + --file "$container_dump" \ + --tempdir /tmp/gitea-dump + +cp "$host_dump" "$target" +sha256sum "$target" > "$target.sha256" +unlink "$host_dump" +printf 'Gitea backup created: %s\n' "$target" diff --git a/flashops/deploy/gitea/docker-compose.yml b/flashops/deploy/gitea/docker-compose.yml new file mode 100644 index 0000000..13948ce --- /dev/null +++ b/flashops/deploy/gitea/docker-compose.yml @@ -0,0 +1,28 @@ +services: + gitea: + image: gitea/gitea:1.27.0 + container_name: gitea + restart: unless-stopped + environment: + USER_UID: "1000" + USER_GID: "1000" + GITEA__database__DB_TYPE: sqlite3 + GITEA__database__PATH: /data/gitea/gitea.db + GITEA__server__DOMAIN: git.imagebrewing.com + GITEA__server__ROOT_URL: https://git.imagebrewing.com/ + GITEA__server__HTTP_PORT: "3000" + GITEA__server__DISABLE_SSH: "true" + GITEA__server__OFFLINE_MODE: "true" + GITEA__service__DISABLE_REGISTRATION: "false" + GITEA__service__REGISTER_MANUAL_CONFIRM: "true" + GITEA__service__REQUIRE_SIGNIN_VIEW: "false" + GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION: "false" + GITEA__repository__DEFAULT_PRIVATE: public + GITEA__security__INSTALL_LOCK: "true" + GITEA__cron__ENABLED: "true" + GITEA__log__MODE: console + GITEA__log__LEVEL: Info + ports: + - "127.0.0.1:13000:3000" + volumes: + - /data/gitea/data:/data diff --git a/flashops/deploy/gitea/gitea-backup.service b/flashops/deploy/gitea/gitea-backup.service new file mode 100644 index 0000000..8530b3b --- /dev/null +++ b/flashops/deploy/gitea/gitea-backup.service @@ -0,0 +1,11 @@ +[Unit] +Description=Back up Gitea to mounted COS bucket +Requires=docker.service +After=docker.service network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/flashops-gitea-backup +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 diff --git a/flashops/deploy/gitea/gitea-backup.timer b/flashops/deploy/gitea/gitea-backup.timer new file mode 100644 index 0000000..e4262eb --- /dev/null +++ b/flashops/deploy/gitea/gitea-backup.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily Gitea backup timer + +[Timer] +OnCalendar=*-*-* 03:20:00 Asia/Shanghai +RandomizedDelaySec=10m +Persistent=true +Unit=flashops-gitea-backup.service + +[Install] +WantedBy=timers.target diff --git a/flashops/deploy/gitea/nginx-http.conf b/flashops/deploy/gitea/nginx-http.conf new file mode 100644 index 0000000..40782fb --- /dev/null +++ b/flashops/deploy/gitea/nginx-http.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name git.imagebrewing.com; + + access_log /www/wwwlogs/git.imagebrewing.com.log; + error_log /www/wwwlogs/git.imagebrewing.com.error.log; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/html; + default_type text/plain; + } + + location / { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} diff --git a/flashops/deploy/gitea/nginx.conf b/flashops/deploy/gitea/nginx.conf new file mode 100644 index 0000000..84cc788 --- /dev/null +++ b/flashops/deploy/gitea/nginx.conf @@ -0,0 +1,47 @@ +server { + listen 80; + server_name git.imagebrewing.com; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/html; + default_type text/plain; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name git.imagebrewing.com; + + ssl_certificate /etc/letsencrypt/live/git.imagebrewing.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/git.imagebrewing.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_timeout 1d; + ssl_session_cache shared:GiteaSSL:10m; + ssl_session_tickets off; + + access_log /www/wwwlogs/git.imagebrewing.com.log; + error_log /www/wwwlogs/git.imagebrewing.com.error.log; + + client_max_body_size 100m; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header X-Frame-Options SAMEORIGIN always; + + location / { + proxy_pass http://127.0.0.1:13000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} diff --git a/flashops/deploy/nginx-http.conf b/flashops/deploy/nginx-http.conf new file mode 100644 index 0000000..946feb0 --- /dev/null +++ b/flashops/deploy/nginx-http.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name flashops.imagebrewing.com; + + access_log /www/wwwlogs/flashops.imagebrewing.com.log; + error_log /www/wwwlogs/flashops.imagebrewing.com.error.log; + + location ^~ /.well-known/acme-challenge/ { + auth_basic off; + root /var/www/html; + default_type text/plain; + } + + location / { + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} diff --git a/flashops/deploy/nginx-https.conf b/flashops/deploy/nginx-https.conf new file mode 100644 index 0000000..fc09a4a --- /dev/null +++ b/flashops/deploy/nginx-https.conf @@ -0,0 +1,47 @@ +server { + listen 80; + server_name flashops.imagebrewing.com; + + location ^~ /.well-known/acme-challenge/ { + auth_basic off; + root /var/www/html; + default_type text/plain; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name flashops.imagebrewing.com; + + ssl_certificate /etc/letsencrypt/live/flashops.imagebrewing.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/flashops.imagebrewing.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_timeout 1d; + ssl_session_cache shared:FlashOpsSSL:10m; + ssl_session_tickets off; + + access_log /www/wwwlogs/flashops.imagebrewing.com.log; + error_log /www/wwwlogs/flashops.imagebrewing.com.error.log; + + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header X-Frame-Options SAMEORIGIN always; + + location / { + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} diff --git a/flashops/docker-compose.yml b/flashops/docker-compose.yml new file mode 100644 index 0000000..347c60d --- /dev/null +++ b/flashops/docker-compose.yml @@ -0,0 +1,78 @@ +# 生产形态(客户内网私有化单机部署)。 +# 开发不需要它:make dev 用 SQLite + 进程内总线 + 本地目录,零外部依赖。 +# +# 注意:Host Agent 不在这里。它跑在客户的测试主机上(Windows 服务 / systemd), +# 不进容器——它要碰真实设备、真实驱动、真实串口。 + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: flashops + POSTGRES_USER: flashops + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?必须设置} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U flashops"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redisdata:/data + restart: unless-stopped + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_USER:-flashops} + MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:?必须设置} + volumes: + - miniodata:/data + ports: + - "9001:9001" # 控制台,仅内网暴露 + restart: unless-stopped + + control-plane: + build: + context: . + dockerfile: services/control-plane/Dockerfile + environment: + FLASHOPS_DATABASE_URL: postgresql+asyncpg://flashops:${POSTGRES_PASSWORD}@postgres:5432/flashops + FLASHOPS_BUS_URL: redis://redis:6379/0 + FLASHOPS_OBJECT_STORE_URL: s3://minio:9000/flashops + FLASHOPS_OBJECT_STORE_KEY: ${MINIO_USER:-flashops} + FLASHOPS_OBJECT_STORE_SECRET: ${MINIO_PASSWORD} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + minio: + condition: service_started + ports: + - "8000:8000" # Agent 要能连上,所以对实验室网段开放 + restart: unless-stopped + + console: + build: + context: . + dockerfile: apps/console/Dockerfile + environment: + FLASHOPS_API_BASE: http://control-plane:8000 + depends_on: + - control-plane + ports: + - "3000:3000" + restart: unless-stopped + +volumes: + pgdata: + redisdata: + miniodata: diff --git a/flashops/docs/adapter-sdk.md b/flashops/docs/adapter-sdk.md new file mode 100644 index 0000000..2945aa7 --- /dev/null +++ b/flashops/docs/adapter-sdk.md @@ -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`: +数字 → ``,十六进制 → ``,路径 → ``,UUID → ``, +时间戳 → ``。所以 +`"nvme0n1: I/O error, sector 12345678 at 2026-07-27T03:14:15"` +归一为 `"nvmen: I/O error, sector at "`。 + +## 这一波带的适配器 + +| 适配器 | 状态 | 说明 | +|---|---|---| +| `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_.py`:至少覆盖 + `precheck` 失败路径、超时路径、`normalize_result` 的两个不同故障输出 +5. 跑 `make test` 里的适配器契约测试(`test_adapter_contract.py` 会对 + `REGISTRY` 里每个适配器自动断言 7 个方法齐全且签名正确) + +## 边界纪律 + +- 适配器**不知道** Run、Workflow、状态机的存在。它只认 `StepSpec` 和 `AdapterContext`。 +- 适配器**不做**重试和恢复决策。挂了就如实报告,重试与恢复是控制平面的事。 +- 适配器**不写**数据库,产物写本地临时目录,由 Agent 上传。 +- 适配器里**不允许**出现 `if customer == "X"` 这种分支。客户差异靠不同适配器 + + 工作流参数表达,不靠代码里的 if。 diff --git a/flashops/docs/agent-protocol.md b/flashops/docs/agent-protocol.md new file mode 100644 index 0000000..a383428 --- /dev/null +++ b/flashops/docs/agent-protocol.md @@ -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 `。`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 掉线是常态, +没有幂等就会出现"一次故障记了三条"的时间线,取证时说不清。 diff --git a/flashops/docs/architecture.md b/flashops/docs/architecture.md new file mode 100644 index 0000000..73da7b4 --- /dev/null +++ b/flashops/docs/architecture.md @@ -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 Bundle(manifest + 事件流) +├─ 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` 是唯一需要替换的文件。 diff --git a/flashops/docs/decisions/0001-monorepo-and-stack.md b/flashops/docs/decisions/0001-monorepo-and-stack.md new file mode 100644 index 0000000..4755ee9 --- /dev/null +++ b/flashops/docs/decisions/0001-monorepo-and-stack.md @@ -0,0 +1,39 @@ +# ADR-0001:Monorepo 与技术栈选型 + +- 日期:2026-07-27 +- 状态:已接受 + +## 背景 + +第一波要同时出前端控制台、控制平面、Host Agent 三个可交付物,团队规模是 1-2 人。 + +## 决定 + +单仓库(monorepo),三个可独立部署的单元:`apps/console`、`services/control-plane`、 +`services/host-agent`。技术栈按方案 §6.1:Next.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 契约不变,只换实现语言。 diff --git a/flashops/docs/decisions/0002-event-sourcing.md b/flashops/docs/decisions/0002-event-sourcing.md new file mode 100644 index 0000000..2a26ca3 --- /dev/null +++ b/flashops/docs/decisions/0002-event-sourcing.md @@ -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。缓解:仓储层不给这个 API,code review 卡这一条。 + +## 什么时候推翻 + +不推翻。真要优化,是加快照(每 N 个事件存一次状态快照加速重放), +不是放弃事件溯源。 diff --git a/flashops/docs/decisions/0003-own-state-machine-over-temporal.md b/flashops/docs/decisions/0003-own-state-machine-over-temporal.md new file mode 100644 index 0000000..dd95a38 --- /dev/null +++ b/flashops/docs/decisions/0003-own-state-machine-over-temporal.md @@ -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 定义里。 +这也是把它们做成纯函数的第二个理由。 diff --git a/flashops/docs/decisions/0004-safety-in-control-plane.md b/flashops/docs/decisions/0004-safety-in-control-plane.md new file mode 100644 index 0000000..c4a7cea --- /dev/null +++ b/flashops/docs/decisions/0004-safety-in-control-plane.md @@ -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。 diff --git a/flashops/docs/decisions/0005-simulator-as-first-class.md b/flashops/docs/decisions/0005-simulator-as-first-class.md new file mode 100644 index 0000000..0272d8e --- /dev/null +++ b/flashops/docs/decisions/0005-simulator-as-first-class.md @@ -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 的默认执行后端。 diff --git a/flashops/docs/domain-model.md b/flashops/docs/domain-model.md new file mode 100644 index 0000000..73c010d --- /dev/null +++ b/flashops/docs/domain-model.md @@ -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)。 diff --git a/flashops/docs/state-machine.md b/flashops/docs/state-machine.md new file mode 100644 index 0000000..16f5db5 --- /dev/null +++ b/flashops/docs/state-machine.md @@ -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`,而不是在纯函数里开个口子。 diff --git a/flashops/docs/workflow-spec.md b/flashops/docs/workflow-spec.md new file mode 100644 index 0000000..bfcfd3c --- /dev/null +++ b/flashops/docs/workflow-spec.md @@ -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 # 每次发布 +1;spec_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 校验时强制。 diff --git a/flashops/services/control-plane/flashops_control/__init__.py b/flashops/services/control-plane/flashops_control/__init__.py new file mode 100644 index 0000000..6cad310 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/__init__.py @@ -0,0 +1,5 @@ +"""FlashOps 控制平面。""" + +from .config import APP_NAME + +__all__ = ["APP_NAME"] diff --git a/flashops/services/control-plane/flashops_control/api/__init__.py b/flashops/services/control-plane/flashops_control/api/__init__.py new file mode 100644 index 0000000..7f786b1 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI 路由。""" diff --git a/flashops/services/control-plane/flashops_control/api/agents.py b/flashops/services/control-plane/flashops_control/api/agents.py new file mode 100644 index 0000000..3a9b3f7 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/api/agents.py @@ -0,0 +1,276 @@ +"""拉模式 Host Agent 的注册、认证与心跳 API。""" +from __future__ import annotations + +import asyncio +import hmac +import secrets +import time +import uuid +from typing import Dict, List, Optional + +from fastapi import APIRouter, Header, HTTPException, Response, status +from sqlalchemy import select + +from ..config import get_settings +from ..db import session_scope +from ..enums import HostStatus +from ..models import TestHost, iso_z, utcnow +from ..schemas import ( + AgentHeartbeatBody, + AgentLeaseBody, + AgentRegisterBody, + AgentStepAttemptBody, + AgentStepCompleteBody, + AgentStepEventsBody, +) +from ..services.agent_status import refresh_agent_host +from ..services.step_leases import ( + StepLeaseConflict, + acknowledge_step, + complete_step, + record_step_events, + renew_step, + try_lease_next_step, +) +from .deps import agent_token_digest, require_agent + +router = APIRouter(prefix="/api/v1/agent", tags=["host-agent"]) + + +def _idempotency_key(value: str) -> str: + if len(value) > 60: + raise HTTPException(status_code=422, detail="Idempotency-Key 过长") + try: + uuid.UUID(value) + except (ValueError, AttributeError) as exc: + raise HTTPException( + status_code=422, detail="Idempotency-Key 必须是 UUID" + ) from exc + return value + + +def _lease_conflict(exc: StepLeaseConflict) -> HTTPException: + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + + +def _check_enrollment_token(provided: Optional[str]) -> None: + settings = get_settings() + required = settings.agent_enrollment_token + if settings.env == "production" and not required: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="生产环境尚未配置 Agent 注册口令", + ) + if required and not hmac.compare_digest(provided or "", required): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Agent 注册口令无效", + ) + + +async def _resolve_host(body: AgentRegisterBody) -> Dict[str, object]: + async with session_scope() as session: + host: Optional[TestHost] = None + if body.host_id: + host = await session.get(TestHost, body.host_id) + if host is None: + raise HTTPException(status_code=404, detail="Test Host 不存在") + elif body.host_name: + host = await session.scalar( + select(TestHost).where(TestHost.name == body.host_name) + ) + else: + host = await session.scalar(select(TestHost).order_by(TestHost.created_at)) + + if host is None: + host = TestHost(name=body.host_name or "agent-host-" + uuid.uuid4().hex[:8]) + session.add(host) + await session.flush() + + raw_token = secrets.token_urlsafe(32) + host.agent_id = host.agent_id or "agent_" + uuid.uuid4().hex[:20] + host.agent_token = agent_token_digest(raw_token) + host.agent_version = body.agent_version + host.os_family = body.os_family + if body.os_version is not None: + host.os_version = body.os_version + if body.kernel is not None: + host.kernel = body.kernel + if body.cpu is not None: + host.cpu = body.cpu + if body.motherboard is not None: + host.motherboard = body.motherboard + if body.bios_version is not None: + host.bios_version = body.bios_version + if body.ip_address is not None: + host.ip_address = body.ip_address + if body.toolchain: + host.toolchain = body.toolchain + if body.labels: + host.labels = {**(host.labels or {}), **body.labels} + host.status = HostStatus.ONLINE.value + host.last_heartbeat_at = utcnow() + payload = { + "agent_id": host.agent_id, + "host_id": host.id, + "token": raw_token, + "heartbeat_interval_s": get_settings().timing.heartbeat_interval_s, + "server_time": iso_z(host.last_heartbeat_at), + } + return payload + + +@router.post("/register", status_code=status.HTTP_201_CREATED) +async def register_agent( + body: AgentRegisterBody, + enrollment_token: Optional[str] = Header( + default=None, alias="X-FlashOps-Enrollment-Token" + ), +) -> Dict[str, object]: + _check_enrollment_token(enrollment_token) + return await _resolve_host(body) + + +@router.post("/{agent_id}/heartbeat") +async def heartbeat( + agent_id: str, + body: AgentHeartbeatBody, + authorization: Optional[str] = Header(default=None), +) -> Dict[str, object]: + async with session_scope() as session: + host = await require_agent(session, agent_id, authorization) + host.status = ( + HostStatus.ONLINE.value if body.healthy else HostStatus.DEGRADED.value + ) + host.last_heartbeat_at = utcnow() + if body.agent_version: + host.agent_version = body.agent_version + if body.ip_address: + host.ip_address = body.ip_address + if body.toolchain: + host.toolchain = body.toolchain + if body.device_probe: + host.labels = { + **(host.labels or {}), + "agent_probe": body.device_probe, + } + return { + "ok": True, + "server_time": iso_z(host.last_heartbeat_at), + "heartbeat_interval_s": get_settings().timing.heartbeat_interval_s, + "commands": [], + } + + +@router.post("/{agent_id}/lease") +async def lease_step( + agent_id: str, + body: AgentLeaseBody, + authorization: Optional[str] = Header(default=None), +) -> object: + deadline = time.monotonic() + min( + body.wait_timeout_s, get_settings().timing.lease_poll_timeout_s + ) + while True: + async with session_scope() as session: + host = await require_agent(session, agent_id, authorization) + lease = await try_lease_next_step(session, host) + if lease is not None: + return lease + remaining = deadline - time.monotonic() + if remaining <= 0: + return Response(status_code=status.HTTP_204_NO_CONTENT) + await asyncio.sleep(min(get_settings().engine_tick_s, remaining)) + + +@router.post("/{agent_id}/steps/{step_id}/ack") +async def ack_step( + agent_id: str, + step_id: str, + body: AgentStepAttemptBody, + authorization: Optional[str] = Header(default=None), + idempotency_key: str = Header(alias="Idempotency-Key"), +) -> Dict[str, object]: + key = _idempotency_key(idempotency_key) + try: + async with session_scope() as session: + host = await require_agent(session, agent_id, authorization) + return await acknowledge_step(session, host, step_id, body.attempt, key) + except StepLeaseConflict as exc: + raise _lease_conflict(exc) from exc + + +@router.post("/{agent_id}/steps/{step_id}/renew") +async def renew_step_lease( + agent_id: str, + step_id: str, + body: AgentStepAttemptBody, + authorization: Optional[str] = Header(default=None), + idempotency_key: str = Header(alias="Idempotency-Key"), +) -> Dict[str, object]: + key = _idempotency_key(idempotency_key) + try: + async with session_scope() as session: + host = await require_agent(session, agent_id, authorization) + return await renew_step(session, host, step_id, body.attempt, key) + except StepLeaseConflict as exc: + raise _lease_conflict(exc) from exc + + +@router.post("/{agent_id}/steps/{step_id}/events") +async def step_events( + agent_id: str, + step_id: str, + body: AgentStepEventsBody, + authorization: Optional[str] = Header(default=None), + idempotency_key: str = Header(alias="Idempotency-Key"), +) -> Dict[str, object]: + key = _idempotency_key(idempotency_key) + try: + async with session_scope() as session: + host = await require_agent(session, agent_id, authorization) + return await record_step_events( + session, host, step_id, body.attempt, body.events, key + ) + except StepLeaseConflict as exc: + raise _lease_conflict(exc) from exc + + +@router.post("/{agent_id}/steps/{step_id}/complete") +async def finish_step( + agent_id: str, + step_id: str, + body: AgentStepCompleteBody, + authorization: Optional[str] = Header(default=None), + idempotency_key: str = Header(alias="Idempotency-Key"), +) -> Dict[str, object]: + key = _idempotency_key(idempotency_key) + try: + async with session_scope() as session: + host = await require_agent(session, agent_id, authorization) + return await complete_step(session, host, step_id, body, key) + except StepLeaseConflict as exc: + raise _lease_conflict(exc) from exc + + +@router.get("/hosts") +async def list_agent_hosts() -> List[Dict[str, object]]: + async with session_scope() as session: + hosts = ( + await session.scalars(select(TestHost).order_by(TestHost.name)) + ).all() + for host in hosts: + refresh_agent_host(host) + return [ + { + "host_id": host.id, + "host_name": host.name, + "agent_id": host.agent_id, + "agent_version": host.agent_version, + "status": host.status, + "last_heartbeat_at": iso_z(host.last_heartbeat_at), + "ip_address": host.ip_address, + "toolchain": host.toolchain, + } + for host in hosts + ] diff --git a/flashops/services/control-plane/flashops_control/api/deps.py b/flashops/services/control-plane/flashops_control/api/deps.py new file mode 100644 index 0000000..bb87e04 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/api/deps.py @@ -0,0 +1,47 @@ +"""HTTP dependencies shared by Agent endpoints.""" +from __future__ import annotations + +import hashlib +import hmac +from typing import Optional + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import TestHost + + +def agent_token_digest(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def bearer_token(authorization: Optional[str]) -> str: + scheme, separator, value = (authorization or "").partition(" ") + if not separator or scheme.lower() != "bearer" or not value.strip(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Agent bearer token 缺失", + headers={"WWW-Authenticate": "Bearer"}, + ) + return value.strip() + + +async def require_agent( + session: AsyncSession, + agent_id: str, + authorization: Optional[str], +) -> TestHost: + host = await session.scalar(select(TestHost).where(TestHost.agent_id == agent_id)) + token = bearer_token(authorization) + if ( + host is None + or not host.agent_token + or not hmac.compare_digest(host.agent_token, agent_token_digest(token)) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Agent 身份无效", + headers={"WWW-Authenticate": "Bearer"}, + ) + return host diff --git a/flashops/services/control-plane/flashops_control/api/routes.py b/flashops/services/control-plane/flashops_control/api/routes.py new file mode 100644 index 0000000..0d00274 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/api/routes.py @@ -0,0 +1,258 @@ +"""控制台所需的最小纵向 API。""" +from __future__ import annotations + +from typing import Dict, List + +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import func, select + +from ..db import session_scope +from ..enums import EventKind, EventSource, RunState, Severity +from ..events import append_event +from ..models import EvidenceBundle, Run, RunEvent, ResourceLock, utcnow +from ..schemas import CreateRunBody, HumanActionBody, PreflightBody +from ..services.runs import ( + cancel_simulation, + create_run, + event_to_dict, + prepare_agent_run, + preflight_for_body, + run_to_dict, + start_simulation, +) + +router = APIRouter(prefix="/api/v1") + + +@router.get("/health") +async def health() -> Dict[str, str]: + return {"status": "ok", "service": "flashops-control-plane"} + + +@router.post("/preflight") +async def preflight(body: PreflightBody) -> Dict[str, object]: + try: + async with session_scope() as session: + return await preflight_for_body(session, body) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/runs", status_code=status.HTTP_201_CREATED) +async def create_run_route(body: CreateRunBody) -> Dict[str, object]: + try: + async with session_scope() as session: + run = await create_run(session, body) + if body.execution_mode == "agent_dry_run": + await prepare_agent_run(session, run) + payload = run_to_dict(run) + if body.execution_mode == "simulated": + start_simulation(run.id) + return payload + except LookupError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/runs/demo", status_code=status.HTTP_201_CREATED) +async def create_demo_run() -> Dict[str, object]: + return await create_run_route(CreateRunBody()) + + +@router.get("/runs") +async def list_runs(limit: int = 30) -> List[Dict[str, object]]: + async with session_scope() as session: + runs = ( + await session.scalars( + select(Run).order_by(Run.created_at.desc()).limit(min(max(limit, 1), 100)) + ) + ).all() + return [run_to_dict(run) for run in runs] + + +@router.get("/runs/{run_id}") +async def get_run(run_id: str) -> Dict[str, object]: + async with session_scope() as session: + run = await session.get(Run, run_id) + if run is None: + raise HTTPException(status_code=404, detail="Run 不存在") + payload = run_to_dict(run) + bundle = await session.scalar( + select(EvidenceBundle) + .where(EvidenceBundle.run_id == run_id) + .order_by(EvidenceBundle.created_at.desc()) + ) + payload["evidence"] = ( + { + "id": bundle.id, + "uri": bundle.uri, + "completeness": bundle.completeness, + "size_bytes": bundle.size_bytes, + } + if bundle + else None + ) + return payload + + +@router.get("/runs/{run_id}/events") +async def get_events(run_id: str, after_seq: int = 0) -> List[Dict[str, object]]: + async with session_scope() as session: + exists = await session.get(Run, run_id) + if exists is None: + raise HTTPException(status_code=404, detail="Run 不存在") + events = ( + await session.scalars( + select(RunEvent) + .where(RunEvent.run_id == run_id, RunEvent.seq > after_seq) + .order_by(RunEvent.seq) + ) + ).all() + return [event_to_dict(event) for event in events] + + +@router.post("/runs/{run_id}/pause") +async def pause_run(run_id: str, body: HumanActionBody) -> Dict[str, object]: + async with session_scope() as session: + run = await session.get(Run, run_id) + if run is None: + raise HTTPException(status_code=404, detail="Run 不存在") + if run.state != RunState.RUNNING.value: + raise HTTPException(status_code=409, detail="只有 RUNNING 任务可以暂停") + await append_event( + session, + run, + EventKind.HUMAN_TOUCH.value, + "{0}: {1}".format(body.actor, body.reason), + source=EventSource.HUMAN.value, + projection={ + "human_touches": run.human_touches + 1, + "unattended_completion": False, + }, + ) + await append_event( + session, + run, + EventKind.RUN_PAUSED.value, + "任务已在安全检查点暂停", + source=EventSource.HUMAN.value, + target_state=RunState.PAUSED.value, + ) + return run_to_dict(run) + + +@router.post("/runs/{run_id}/resume") +async def resume_run(run_id: str, body: HumanActionBody) -> Dict[str, object]: + async with session_scope() as session: + run = await session.get(Run, run_id) + if run is None: + raise HTTPException(status_code=404, detail="Run 不存在") + if run.state != RunState.PAUSED.value: + raise HTTPException(status_code=409, detail="只有 PAUSED 任务可以继续") + await append_event( + session, + run, + EventKind.HUMAN_TOUCH.value, + "{0}: {1}".format(body.actor, body.reason), + source=EventSource.HUMAN.value, + projection={ + "human_touches": run.human_touches + 1, + "unattended_completion": False, + }, + ) + await append_event( + session, + run, + EventKind.RUN_RESUMED.value, + "人工确认后从检查点继续", + source=EventSource.HUMAN.value, + target_state=RunState.RUNNING.value, + ) + return run_to_dict(run) + + +@router.post("/runs/{run_id}/emergency-stop") +async def emergency_stop(run_id: str, body: HumanActionBody) -> Dict[str, object]: + async with session_scope() as session: + run = await session.get(Run, run_id) + if run is None: + raise HTTPException(status_code=404, detail="Run 不存在") + if run.state in { + RunState.COMPLETED.value, + RunState.ABORTED.value, + RunState.FROZEN.value, + RunState.REJECTED.value, + }: + raise HTTPException(status_code=409, detail="终态任务不能再次停止") + await append_event( + session, + run, + EventKind.HUMAN_TOUCH.value, + "{0}: {1}".format(body.actor, body.reason), + source=EventSource.HUMAN.value, + severity=Severity.CRITICAL.value, + projection={ + "human_touches": run.human_touches + 1, + "unattended_completion": False, + }, + ) + await append_event( + session, + run, + EventKind.RUN_ABORTED.value, + "紧急停止已执行,现场与检查点已保留", + source=EventSource.HUMAN.value, + severity=Severity.CRITICAL.value, + target_state=RunState.ABORTED.value, + projection={"ended_at": utcnow()}, + ) + await session.execute( + ResourceLock.__table__.delete().where(ResourceLock.run_id == run.id) + ) + payload = run_to_dict(run) + cancel_simulation(run_id) + return payload + + +@router.get("/dashboard/summary") +async def dashboard_summary() -> Dict[str, object]: + async with session_scope() as session: + total = await session.scalar(select(func.count()).select_from(Run)) or 0 + running = ( + await session.scalar( + select(func.count()).select_from(Run).where( + Run.state.in_( + [ + RunState.QUEUED.value, + RunState.PREFLIGHT.value, + RunState.RUNNING.value, + RunState.RECOVERING.value, + ] + ) + ) + ) + or 0 + ) + completed = ( + await session.scalar( + select(func.count()).select_from(Run).where( + Run.state == RunState.COMPLETED.value + ) + ) + or 0 + ) + unattended = ( + await session.scalar( + select(func.count()).select_from(Run).where( + Run.state == RunState.COMPLETED.value, + Run.unattended_completion.is_(True), + ) + ) + or 0 + ) + ucr = round(unattended / completed * 100, 1) if completed else 0.0 + return { + "total_runs": total, + "active_runs": running, + "completed_runs": completed, + "unattended_completion_rate": ucr, + } diff --git a/flashops/services/control-plane/flashops_control/cli.py b/flashops/services/control-plane/flashops_control/cli.py new file mode 100644 index 0000000..56e90e6 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/cli.py @@ -0,0 +1,74 @@ +"""开发与演示命令行。""" +from __future__ import annotations + +import argparse +import asyncio + +from sqlalchemy import select + +from .db import drop_db, init_db, session_scope +from .models import EvidenceBundle, Run +from .schemas import CreateRunBody +from .seed import ensure_seed_data +from .services.runs import create_run, run_to_dict, simulate_run + + +async def _init_db() -> None: + await init_db() + + +async def _seed() -> None: + await init_db() + async with session_scope() as session: + await ensure_seed_data(session) + + +async def _reset() -> None: + await drop_db() + await init_db() + await _seed() + + +async def _demo() -> None: + await _seed() + async with session_scope() as session: + run = await create_run( + session, + CreateRunBody(loops=8, inject_failure=True, created_by="cli-demo"), + ) + run_id = run.id + print("Run {0} 已启动:将注入一次心跳丢失并自动升级到 L3 恢复。".format(run_id)) + await simulate_run(run_id) + async with session_scope() as session: + run = await session.get(Run, run_id) + bundle = await session.scalar( + select(EvidenceBundle) + .where(EvidenceBundle.run_id == run_id) + .order_by(EvidenceBundle.created_at.desc()) + ) + assert run is not None + payload = run_to_dict(run) + print( + "完成:{state} / {verdict},循环 {loop_done}/{loop_target},恢复 {recovery_count} 次。".format( + **payload + ) + ) + if bundle: + print("Evidence Bundle: {0}".format(bundle.uri)) + + +def main() -> None: + parser = argparse.ArgumentParser(prog="flashops") + parser.add_argument("command", choices=["init-db", "seed", "reset-db", "demo"]) + args = parser.parse_args() + commands = { + "init-db": _init_db, + "seed": _seed, + "reset-db": _reset, + "demo": _demo, + } + asyncio.run(commands[args.command]()) + + +if __name__ == "__main__": + main() diff --git a/flashops/services/control-plane/flashops_control/config.py b/flashops/services/control-plane/flashops_control/config.py new file mode 100644 index 0000000..57bd714 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/config.py @@ -0,0 +1,107 @@ +"""配置。 + +产品名只出现在这里和 apps/console/lib/brand.ts —— 改名成本 = 改两个常量。 +商标检索未完成前,任何地方都不要再硬编码产品名。 +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +APP_NAME = "FlashOps" +APP_TAGLINE = "存储实验室无人值守执行层" + +# 仓库根目录:.../flashops +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _env(key: str, default: str) -> str: + return os.environ.get(f"FLASHOPS_{key}", default) + + +def _env_int(key: str, default: int) -> int: + return int(os.environ.get(f"FLASHOPS_{key}", str(default))) + + +def _env_float(key: str, default: float) -> float: + return float(os.environ.get(f"FLASHOPS_{key}", str(default))) + + +@dataclass(frozen=True) +class TimingPolicy: + """时间策略。 + + 全部集中在这里,且引擎不直接读它 —— 引擎从 Snapshot 拿数值。 + 这样"30 秒心跳超时"这类规则可以在测试里用 0.03 秒验证。 + """ + + heartbeat_interval_s: float = 5.0 + heartbeat_degraded_after_s: float = 15.0 + heartbeat_lost_after_s: float = 30.0 + lease_ttl_s: float = 60.0 + lease_poll_timeout_s: float = 20.0 + step_default_timeout_s: float = 600.0 + recovery_settle_s: float = 20.0 # 恢复动作后等待主机回来的时间 + ac_cycle_off_s: float = 10.0 # AC 断电后的安全间隔(方案 §7.2) + max_recovery_per_loop: int = 3 + max_recovery_total: int = 20 + + +@dataclass(frozen=True) +class Settings: + app_name: str = APP_NAME + env: str = field(default_factory=lambda: _env("ENV", "development")) + + # 开发态:SQLite。生产:postgresql+asyncpg://... + database_url: str = field( + default_factory=lambda: _env( + "DATABASE_URL", "sqlite+aiosqlite:///" + str(REPO_ROOT / "var" / "flashops.db") + ) + ) + # 开发态:本地目录。生产:s3://minio:9000/flashops + object_store_url: str = field( + default_factory=lambda: _env("OBJECT_STORE_URL", str(REPO_ROOT / "var" / "objects")) + ) + # 开发态:进程内 asyncio。生产:redis://... / nats://... + bus_url: Optional[str] = field(default_factory=lambda: os.environ.get("FLASHOPS_BUS_URL")) + # Agent 首次注册口令。开发态未配置时允许本机演示;生产态缺失时拒绝注册。 + agent_enrollment_token: Optional[str] = field( + default_factory=lambda: os.environ.get("FLASHOPS_AGENT_ENROLLMENT_TOKEN") + ) + + api_host: str = field(default_factory=lambda: _env("API_HOST", "0.0.0.0")) + api_port: int = field(default_factory=lambda: _env_int("API_PORT", 8000)) + cors_origins: str = field(default_factory=lambda: _env("CORS_ORIGINS", "http://localhost:3000")) + + # 引擎主循环的 tick 间隔。demo/测试里调小以加速。 + engine_tick_s: float = field(default_factory=lambda: _env_float("ENGINE_TICK_S", 0.5)) + # 演示/仿真加速倍率:1.0 = 真实速度 + time_scale: float = field(default_factory=lambda: _env_float("TIME_SCALE", 1.0)) + + timing: TimingPolicy = field(default_factory=TimingPolicy) + + @property + def is_sqlite(self) -> bool: + return self.database_url.startswith("sqlite") + + @property + def object_store_path(self) -> Path: + return Path(self.object_store_url) + + +_settings: Optional[Settings] = None + + +def get_settings() -> Settings: + global _settings + if _settings is None: + _settings = Settings() + return _settings + + +def reset_settings() -> None: + """测试用:让下一次 get_settings() 重新读环境变量。""" + global _settings + _settings = None diff --git a/flashops/services/control-plane/flashops_control/db.py b/flashops/services/control-plane/flashops_control/db.py new file mode 100644 index 0000000..b9eabb8 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/db.py @@ -0,0 +1,106 @@ +"""异步数据库连接与会话。 + +连接按当前配置惰性创建,测试可以安全地切换到临时 SQLite 数据库。 +""" +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional +from weakref import WeakKeyDictionary + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from .config import get_settings +from .models import Base + +_engine: Optional[AsyncEngine] = None +_engine_url: Optional[str] = None +_session_factory: Optional[async_sessionmaker[AsyncSession]] = None +_sqlite_session_locks: WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = ( + WeakKeyDictionary() +) + + +def _sqlite_session_lock() -> asyncio.Lock: + """Return one transaction lock per event loop for the SQLite prototype. + + SQLite permits only one writer at a time. Serializing the complete + session_scope transaction prevents the simulator and human control routes + from reading the same event sequence and then racing on commit. PostgreSQL + deployments skip this application-level lock. + """ + + loop = asyncio.get_running_loop() + lock = _sqlite_session_locks.get(loop) + if lock is None: + lock = asyncio.Lock() + _sqlite_session_locks[loop] = lock + return lock + + +def get_engine() -> AsyncEngine: + global _engine, _engine_url, _session_factory + url = get_settings().database_url + if _engine is None or _engine_url != url: + _engine = create_async_engine(url, future=True) + _engine_url = url + _session_factory = async_sessionmaker(_engine, expire_on_commit=False) + return _engine + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + get_engine() + assert _session_factory is not None + return _session_factory + + +@asynccontextmanager +async def _transaction_scope() -> AsyncIterator[AsyncSession]: + session = get_session_factory()() + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +@asynccontextmanager +async def session_scope() -> AsyncIterator[AsyncSession]: + if get_settings().is_sqlite: + async with _sqlite_session_lock(): + async with _transaction_scope() as session: + yield session + return + + async with _transaction_scope() as session: + yield session + + +async def init_db() -> None: + engine = get_engine() + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + + +async def drop_db() -> None: + engine = get_engine() + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.drop_all) + + +async def dispose_db() -> None: + global _engine, _engine_url, _session_factory + if _engine is not None: + await _engine.dispose() + _engine = None + _engine_url = None + _session_factory = None diff --git a/flashops/services/control-plane/flashops_control/engine/__init__.py b/flashops/services/control-plane/flashops_control/engine/__init__.py new file mode 100644 index 0000000..ad213d3 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/engine/__init__.py @@ -0,0 +1,5 @@ +"""无 I/O 的编排规则。""" + +from .states import IllegalTransition, assert_run_transition + +__all__ = ["IllegalTransition", "assert_run_transition"] diff --git a/flashops/services/control-plane/flashops_control/engine/planner.py b/flashops/services/control-plane/flashops_control/engine/planner.py new file mode 100644 index 0000000..35f3bf3 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/engine/planner.py @@ -0,0 +1,159 @@ +"""Materialize a published Workflow snapshot into sequential RunStep rows. + +The planner deliberately supports a small, deterministic subset of the workflow +schema. It never evaluates expressions and it never turns free-form text into a +command. Host Agents only receive already-published adapter/template references. +""" +from __future__ import annotations + +import re +from typing import Any, Dict, List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config import get_settings +from ..enums import OnFail, StepType +from ..models import Run, RunStep, Workflow + +_PARAM = re.compile(r"^\{\{\s*params\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$") +_LOOP_INDEX = re.compile(r"^\{\{\s*loop\.index\s*\}\}$") +_HOST_STEP_TYPES = { + StepType.COMMAND.value, + StepType.DEVICE_CHECK.value, + StepType.WAIT.value, +} + + +class WorkflowPlanningError(ValueError): + """The published workflow cannot be safely materialized.""" + + +def _resolve(value: Any, run_params: Dict[str, Any], loop_index: Optional[int]) -> Any: + """Resolve only exact, allow-listed template tokens; no expression evaluation.""" + + if isinstance(value, str): + parameter = _PARAM.fullmatch(value) + if parameter: + key = parameter.group(1) + if key not in run_params: + raise WorkflowPlanningError("工作流参数不存在: {0}".format(key)) + return run_params[key] + if _LOOP_INDEX.fullmatch(value): + if loop_index is None: + raise WorkflowPlanningError("loop.index 只能在循环体内使用") + return loop_index + if "{{" in value or "}}" in value: + raise WorkflowPlanningError("模板只允许完整的 params.X 或 loop.index 占位符") + return value + if isinstance(value, dict): + return {key: _resolve(item, run_params, loop_index) for key, item in value.items()} + if isinstance(value, list): + return [_resolve(item, run_params, loop_index) for item in value] + return value + + +def _step_row( + *, + run: Run, + spec: Dict[str, Any], + seq: int, + loop_index: Optional[int], + checkpoint_after: bool, +) -> RunStep: + step_type = str(spec.get("type", "")) + if step_type not in _HOST_STEP_TYPES: + raise WorkflowPlanningError("Host Agent 不支持步骤类型: {0}".format(step_type or "")) + key = str(spec.get("key", "")).strip() + adapter = str(spec.get("adapter", "")).strip() + if not key or not adapter: + raise WorkflowPlanningError("步骤 key 与 adapter 不能为空") + + retry = int(spec.get("retry", 0)) + timeout_s = float(spec.get("timeout_s", get_settings().timing.step_default_timeout_s)) + if retry < 0 or retry > 20: + raise WorkflowPlanningError("步骤 retry 必须在 0..20 之间") + if timeout_s <= 0: + raise WorkflowPlanningError("步骤 timeout_s 必须大于 0") + + return RunStep( + run_id=run.id, + seq=seq, + loop_index=loop_index, + step_key=key, + step_type=step_type, + adapter=adapter, + template=spec.get("template"), + params=_resolve(dict(spec.get("params") or {}), run.params, loop_index), + max_retry=retry, + timeout_s=timeout_s, + idempotent=bool(spec.get("idempotent", False)), + danger=str(spec.get("danger", "none")), + on_fail=str(spec.get("on_fail", OnFail.RETRY.value)), + checkpoint_after=checkpoint_after, + ) + + +def plan_run_steps(run: Run, workflow: Workflow) -> List[RunStep]: + """Expand one workflow snapshot into the exact ordered steps for a Run.""" + + if not workflow.published: + raise WorkflowPlanningError("工作流尚未发布") + raw_steps = workflow.spec.get("steps") if isinstance(workflow.spec, dict) else None + if not isinstance(raw_steps, list) or not raw_steps: + raise WorkflowPlanningError("工作流没有可执行步骤") + + planned: List[RunStep] = [] + seq = 1 + for raw in raw_steps: + if not isinstance(raw, dict): + raise WorkflowPlanningError("工作流步骤必须是 object") + step_type = str(raw.get("type", "")) + + # Safety preflight and evidence/report generation live in the control plane. + if step_type in {StepType.PRECHECK.value, StepType.REPORT.value}: + continue + + if step_type == StepType.LOOP.value: + body = raw.get("body") + if not isinstance(body, list) or not body: + raise WorkflowPlanningError("循环步骤必须包含非空 body") + for loop_index in range(1, run.loop_target + 1): + for index, child in enumerate(body): + if not isinstance(child, dict): + raise WorkflowPlanningError("循环体步骤必须是 object") + planned.append( + _step_row( + run=run, + spec=child, + seq=seq, + loop_index=loop_index, + checkpoint_after=bool(child.get("checkpoint", False)) + or (bool(raw.get("checkpoint", False)) and index == len(body) - 1), + ) + ) + seq += 1 + continue + + planned.append( + _step_row( + run=run, + spec=raw, + seq=seq, + loop_index=None, + checkpoint_after=bool(raw.get("checkpoint", False)), + ) + ) + seq += 1 + + if not planned: + raise WorkflowPlanningError("工作流没有 Host Agent 可执行步骤") + return planned + + +async def materialize_run_steps( + session: AsyncSession, run: Run, workflow: Workflow +) -> List[RunStep]: + planned = plan_run_steps(run, workflow) + session.add_all(planned) + await session.flush() + return planned diff --git a/flashops/services/control-plane/flashops_control/engine/recovery.py b/flashops/services/control-plane/flashops_control/engine/recovery.py new file mode 100644 index 0000000..0ac67e8 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/engine/recovery.py @@ -0,0 +1,37 @@ +"""五级恢复阶梯的纯函数决策。""" +from __future__ import annotations + +from dataclasses import dataclass + +from ..enums import RecoveryLevel, RecoveryTrigger + + +@dataclass(frozen=True) +class RecoveryContext: + trigger: RecoveryTrigger + attempt: int + agent_online: bool + host_network_online: bool + oob_online: bool + + +def next_recovery_level(context: RecoveryContext) -> RecoveryLevel: + if context.trigger == RecoveryTrigger.DATA_INTEGRITY: + return RecoveryLevel.FREEZE + + ladder = [] + if context.agent_online: + ladder.append(RecoveryLevel.AGENT_SOFT) + if context.host_network_online: + ladder.append(RecoveryLevel.OS_REBOOT) + if context.oob_online: + ladder.extend( + [ + RecoveryLevel.OOB_RESET, + RecoveryLevel.OOB_ATX_POWER, + RecoveryLevel.OOB_AC_CYCLE, + ] + ) + if context.attempt >= len(ladder): + return RecoveryLevel.FREEZE + return ladder[context.attempt] diff --git a/flashops/services/control-plane/flashops_control/engine/states.py b/flashops/services/control-plane/flashops_control/engine/states.py new file mode 100644 index 0000000..147c70d --- /dev/null +++ b/flashops/services/control-plane/flashops_control/engine/states.py @@ -0,0 +1,44 @@ +"""Run 状态转移表。""" +from __future__ import annotations + +from typing import Dict, FrozenSet + +from ..enums import RunState + + +class IllegalTransition(ValueError): + pass + + +RUN_TRANSITIONS: Dict[RunState, FrozenSet[RunState]] = { + RunState.QUEUED: frozenset({RunState.PREFLIGHT, RunState.ABORTED}), + RunState.PREFLIGHT: frozenset( + {RunState.RUNNING, RunState.REJECTED, RunState.ABORTED} + ), + RunState.RUNNING: frozenset( + { + RunState.RECOVERING, + RunState.PAUSED, + RunState.FROZEN, + RunState.COMPLETED, + RunState.ABORTED, + } + ), + RunState.RECOVERING: frozenset( + {RunState.RUNNING, RunState.FROZEN, RunState.ABORTED} + ), + RunState.PAUSED: frozenset({RunState.RUNNING, RunState.ABORTED}), + RunState.FROZEN: frozenset(), + RunState.COMPLETED: frozenset(), + RunState.ABORTED: frozenset(), + RunState.REJECTED: frozenset(), +} + + +def assert_run_transition(current: str, target: str) -> None: + current_state = RunState(current) + target_state = RunState(target) + if target_state not in RUN_TRANSITIONS[current_state]: + raise IllegalTransition( + "Run 状态不允许从 {0} 转到 {1}".format(current_state.value, target_state.value) + ) diff --git a/flashops/services/control-plane/flashops_control/enums.py b/flashops/services/control-plane/flashops_control/enums.py new file mode 100644 index 0000000..4fe206e --- /dev/null +++ b/flashops/services/control-plane/flashops_control/enums.py @@ -0,0 +1,234 @@ +"""全部领域枚举。 + +放在包根、不依赖任何东西 —— models / engine / schemas / api 都从这里取, +保证"状态"这个概念在全仓库只有一份定义。 +""" +from __future__ import annotations + +from enum import Enum + + +class StrEnum(str, Enum): + """Python 3.9 没有 enum.StrEnum,自己来一个。""" + + def __str__(self) -> str: # pragma: no cover - 只影响日志可读性 + return self.value + + +# ---------------------------------------------------------------- Run / Step + +class RunState(StrEnum): + QUEUED = "QUEUED" # 等资源 + PREFLIGHT = "PREFLIGHT" # 安全门禁 + 环境指纹冻结 + RUNNING = "RUNNING" + RECOVERING = "RECOVERING" # 恢复阶梯进行中 + PAUSED = "PAUSED" # 人工暂停 + FROZEN = "FROZEN" # 冻结现场,停止一切覆盖性动作(终态) + COMPLETED = "COMPLETED" # 终态 + ABORTED = "ABORTED" # 紧急停止(终态) + REJECTED = "REJECTED" # 安全门禁拒绝(终态) + + +class StepState(StrEnum): + PENDING = "PENDING" + DISPATCHED = "DISPATCHED" # 已租给 Agent,等确认接手 + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + TIMED_OUT = "TIMED_OUT" + SKIPPED = "SKIPPED" + CANCELLED = "CANCELLED" + + +class Verdict(StrEnum): + PASS = "PASS" + FAIL = "FAIL" + INCONCLUSIVE = "INCONCLUSIVE" # 非幂等步骤中断后不自动重跑 → 需人工确认 + + +# ------------------------------------------------------------------ 恢复阶梯 + +class RecoveryLevel(StrEnum): + """方案 §7.2 的五级阶梯。顺序即升级顺序。""" + + AGENT_SOFT = "L1_AGENT_SOFT" # Agent 优雅停止 / 软重启进程 + OS_REBOOT = "L2_OS_REBOOT" # OS 远程通道重启 + OOB_RESET = "L3_OOB_RESET" # 带外触发主板 Reset + OOB_ATX_POWER = "L4_OOB_ATX_POWER" # 带外模拟 ATX 长按关机再开机 + OOB_AC_CYCLE = "L5_OOB_AC_CYCLE" # 整机 AC 断电 → 安全间隔 → 上电 + FREEZE = "FREEZE" # 冻结现场,不再自动恢复 + + @property + def requires_oob(self) -> bool: + return self in ( + RecoveryLevel.OOB_RESET, + RecoveryLevel.OOB_ATX_POWER, + RecoveryLevel.OOB_AC_CYCLE, + ) + + +class RecoveryOutcome(StrEnum): + RECOVERED = "RECOVERED" + FAILED = "FAILED" + ESCALATED = "ESCALATED" + FROZEN = "FROZEN" + + +class RecoveryTrigger(StrEnum): + HEARTBEAT_LOST = "HEARTBEAT_LOST" + STEP_TIMEOUT = "STEP_TIMEOUT" + HOST_CRASH = "HOST_CRASH" # 蓝屏 / kernel panic + DUT_NOT_ENUMERATED = "DUT_NOT_ENUMERATED" + DATA_INTEGRITY = "DATA_INTEGRITY" # → 直接 FREEZE,不走阶梯 + MANUAL = "MANUAL" + + +# -------------------------------------------------------------------- 资产 + +class HostStatus(StrEnum): + ONLINE = "ONLINE" + DEGRADED = "DEGRADED" # 心跳迟到但未判失联 + OFFLINE = "OFFLINE" + UNKNOWN = "UNKNOWN" + + +class DutStatus(StrEnum): + IDLE = "IDLE" + IN_USE = "IN_USE" + QUARANTINED = "QUARANTINED" # 疑似坏盘,隔离待人工确认 + MISSING = "MISSING" # 探针找不到 + + +class PowerState(StrEnum): + ON = "ON" + OFF = "OFF" + UNKNOWN = "UNKNOWN" + + +class DangerLevel(StrEnum): + NONE = "none" + LOW = "low" + HIGH = "high" # 需要审批记录 + + +# -------------------------------------------------------------------- 失败 + +class FailureClass(StrEnum): + """INFRA_FAILURE 必须与 DUT_DEFECT 分开统计 —— 见 domain-model.md。 + + 把网络断了算成 SSD 缺陷,客户第一周就不信任这套系统。 + """ + + DUT_DEFECT = "DUT_DEFECT" + INFRA_FAILURE = "INFRA_FAILURE" + SCRIPT_FAILURE = "SCRIPT_FAILURE" + DATA_INTEGRITY = "DATA_INTEGRITY" + UNKNOWN = "UNKNOWN" + + +class IntegrityState(StrEnum): + OK = "OK" + MISMATCH = "MISMATCH" + NOT_CHECKED = "NOT_CHECKED" + + +# ------------------------------------------------------------- 事件(时间线) + +class EventSource(StrEnum): + CONTROL_PLANE = "control_plane" + AGENT = "agent" + OOB = "oob" # 带外通道 —— 主机死后唯一还在说话的 + HUMAN = "human" + + +class Severity(StrEnum): + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class EventKind(StrEnum): + """统一时间线的事件种类。 + + 新增事件种类是常事;删除/改名不是 —— 历史事件已经落库,改名会让旧 Run 的 + 时间线读不出来。只增不改。 + """ + + # Run 生命周期 + RUN_CREATED = "RUN_CREATED" + RUN_QUEUED = "RUN_QUEUED" + RUN_PREFLIGHT_STARTED = "RUN_PREFLIGHT_STARTED" + RUN_PREFLIGHT_PASSED = "RUN_PREFLIGHT_PASSED" + RUN_REJECTED = "RUN_REJECTED" + RUN_STARTED = "RUN_STARTED" + RUN_PAUSED = "RUN_PAUSED" + RUN_RESUMED = "RUN_RESUMED" + RUN_COMPLETED = "RUN_COMPLETED" + RUN_ABORTED = "RUN_ABORTED" + RUN_FROZEN = "RUN_FROZEN" + + # 循环与步骤 + LOOP_STARTED = "LOOP_STARTED" + LOOP_COMPLETED = "LOOP_COMPLETED" + STEP_DISPATCHED = "STEP_DISPATCHED" + STEP_LEASE_RENEWED = "STEP_LEASE_RENEWED" + STEP_LEASE_EXPIRED = "STEP_LEASE_EXPIRED" + STEP_STARTED = "STEP_STARTED" + STEP_OUTPUT = "STEP_OUTPUT" + STEP_SUCCEEDED = "STEP_SUCCEEDED" + STEP_FAILED = "STEP_FAILED" + STEP_TIMED_OUT = "STEP_TIMED_OUT" + STEP_RETRYING = "STEP_RETRYING" + STEP_CANCELLED = "STEP_CANCELLED" + + # 检查点 + CHECKPOINT_SAVED = "CHECKPOINT_SAVED" + CHECKPOINT_RESUMED = "CHECKPOINT_RESUMED" + + # 心跳与双通道观测 + HEARTBEAT_DEGRADED = "HEARTBEAT_DEGRADED" + HEARTBEAT_LOST = "HEARTBEAT_LOST" + HEARTBEAT_RECOVERED = "HEARTBEAT_RECOVERED" + OOB_OBSERVATION = "OOB_OBSERVATION" + OOB_POWER_STATE = "OOB_POWER_STATE" + + # 恢复 + RECOVERY_STARTED = "RECOVERY_STARTED" + RECOVERY_ESCALATED = "RECOVERY_ESCALATED" + RECOVERY_SUCCEEDED = "RECOVERY_SUCCEEDED" + RECOVERY_FAILED = "RECOVERY_FAILED" + + # 设备与数据 + DUT_ENUMERATION_LOST = "DUT_ENUMERATION_LOST" + DUT_ENUMERATION_RESTORED = "DUT_ENUMERATION_RESTORED" + INTEGRITY_MISMATCH = "INTEGRITY_MISMATCH" + + # 安全与资源 + SAFETY_APPROVED = "SAFETY_APPROVED" + SAFETY_REJECTED = "SAFETY_REJECTED" + LOCK_ACQUIRED = "LOCK_ACQUIRED" + LOCK_RELEASED = "LOCK_RELEASED" + + # 人与产物 + HUMAN_TOUCH = "HUMAN_TOUCH" # 每一次人工介入都记 —— UCR 的分母来源 + FAILURE_SIGNED = "FAILURE_SIGNED" + EVIDENCE_BUNDLED = "EVIDENCE_BUNDLED" + + +class StepType(StrEnum): + COMMAND = "command" + DEVICE_CHECK = "device_check" + WAIT = "wait" + LOOP = "loop" + PRECHECK = "precheck" + REPORT = "report" + + +class OnFail(StrEnum): + RETRY = "retry" + FAIL_RUN = "fail_run" + CONTINUE = "continue" + FREEZE = "freeze" + RECOVER = "recover" diff --git a/flashops/services/control-plane/flashops_control/events/__init__.py b/flashops/services/control-plane/flashops_control/events/__init__.py new file mode 100644 index 0000000..465d278 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/events/__init__.py @@ -0,0 +1,5 @@ +"""事件溯源写入。""" + +from .recorder import append_event + +__all__ = ["append_event"] diff --git a/flashops/services/control-plane/flashops_control/events/recorder.py b/flashops/services/control-plane/flashops_control/events/recorder.py new file mode 100644 index 0000000..f02ea1b --- /dev/null +++ b/flashops/services/control-plane/flashops_control/events/recorder.py @@ -0,0 +1,54 @@ +"""Run 事件唯一写入口,同时维护当前状态投影。""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from ..engine.states import assert_run_transition +from ..enums import EventSource, Severity +from ..models import Run, RunEvent, utcnow + + +async def append_event( + session: AsyncSession, + run: Run, + kind: str, + message: str, + *, + source: str = EventSource.CONTROL_PLANE.value, + severity: str = Severity.INFO.value, + payload: Optional[Dict[str, Any]] = None, + target_state: Optional[str] = None, + projection: Optional[Dict[str, Any]] = None, + idempotency_key: Optional[str] = None, + step_id: Optional[str] = None, + loop_index: Optional[int] = None, +) -> RunEvent: + if target_state is not None and target_state != run.state: + assert_run_transition(run.state, target_state) + + run.event_seq += 1 + event = RunEvent( + run_id=run.id, + seq=run.event_seq, + ts=utcnow(), + source=source, + kind=kind, + severity=severity, + message=message, + payload=payload or {}, + idempotency_key=idempotency_key, + step_id=step_id, + loop_index=loop_index, + ) + session.add(event) + + if target_state is not None: + run.state = target_state + for key, value in (projection or {}).items(): + if not hasattr(run, key): + raise AttributeError("Run 不存在投影字段: {0}".format(key)) + setattr(run, key, value) + await session.flush() + return event diff --git a/flashops/services/control-plane/flashops_control/evidence/__init__.py b/flashops/services/control-plane/flashops_control/evidence/__init__.py new file mode 100644 index 0000000..0c1a838 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/evidence/__init__.py @@ -0,0 +1,5 @@ +"""Evidence Bundle 生成。""" + +from .bundle import build_evidence_bundle + +__all__ = ["build_evidence_bundle"] diff --git a/flashops/services/control-plane/flashops_control/evidence/bundle.py b/flashops/services/control-plane/flashops_control/evidence/bundle.py new file mode 100644 index 0000000..aa4a23d --- /dev/null +++ b/flashops/services/control-plane/flashops_control/evidence/bundle.py @@ -0,0 +1,69 @@ +"""把一次 Run 的可重放事件打成最小证据包。""" +from __future__ import annotations + +import json +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config import get_settings +from ..models import EvidenceBundle, Run, RunEvent, iso_z + + +async def build_evidence_bundle(session: AsyncSession, run: Run) -> EvidenceBundle: + events = ( + await session.scalars( + select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq) + ) + ).all() + root = get_settings().object_store_path / "runs" / run.id + root.mkdir(parents=True, exist_ok=True) + timeline_path = root / "events.ndjson" + timeline = "\n".join( + json.dumps( + { + "seq": event.seq, + "ts": iso_z(event.ts), + "source": event.source, + "kind": event.kind, + "severity": event.severity, + "message": event.message, + "payload": event.payload, + }, + ensure_ascii=False, + sort_keys=True, + ) + for event in events + ) + timeline_path.write_text(timeline + ("\n" if timeline else ""), encoding="utf-8") + manifest = { + "schema_version": 1, + "run_id": run.id, + "workflow": { + "key": run.workflow_key, + "version": run.workflow_version, + "spec_hash": run.spec_hash, + }, + "environment_fingerprint": run.env_fingerprint, + "checkpoint": run.checkpoint, + "event_count": len(events), + "files": [{"path": "events.ndjson", "bytes": timeline_path.stat().st_size}], + } + manifest_path = root / "manifest.json" + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + size = manifest_path.stat().st_size + timeline_path.stat().st_size + bundle = EvidenceBundle( + run_id=run.id, + uri=str(root), + manifest=manifest, + size_bytes=size, + completeness=1.0, + missing_fields=[], + ) + session.add(bundle) + await session.flush() + return bundle diff --git a/flashops/services/control-plane/flashops_control/main.py b/flashops/services/control-plane/flashops_control/main.py new file mode 100644 index 0000000..92310d9 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/main.py @@ -0,0 +1,53 @@ +"""FlashOps FastAPI 应用。""" +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles + +from .api.agents import router as agent_router +from .api.routes import router +from .config import APP_NAME, APP_TAGLINE, REPO_ROOT, get_settings +from .db import init_db, session_scope +from .seed import ensure_seed_data +from .services.agent_status import run_agent_watchdog, stop_agent_watchdog + + +@asynccontextmanager +async def lifespan(_: FastAPI): + await init_db() + async with session_scope() as session: + await ensure_seed_data(session) + watchdog = asyncio.create_task(run_agent_watchdog(), name="agent-heartbeat-watchdog") + try: + yield + finally: + await stop_agent_watchdog(watchdog) + + +app = FastAPI(title=APP_NAME, description=APP_TAGLINE, version="0.1.0", lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=[origin.strip() for origin in get_settings().cors_origins.split(",")], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(router) +app.include_router(agent_router) + +console_dir = REPO_ROOT.parent / "前端UI八页面完成" +if console_dir.exists(): + app.mount("/console", StaticFiles(directory=str(console_dir), html=True), name="console") + + +@app.get("/", include_in_schema=False) +async def root() -> RedirectResponse: + if console_dir.exists(): + return RedirectResponse("/console/Dashboard.dc.html") + return RedirectResponse("/docs") diff --git a/flashops/services/control-plane/flashops_control/models/__init__.py b/flashops/services/control-plane/flashops_control/models/__init__.py new file mode 100644 index 0000000..9a7d8d6 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/models/__init__.py @@ -0,0 +1,31 @@ +"""11 张表 → 方案 §5.3 的 7 个核心领域对象。映射见 docs/domain-model.md。""" +from .analysis import AuditLog, EvidenceBundle, FailureSignature, RunFailure +from .assets import Dut, FirmwareArtifact, OobController, TestHost +from .base import Base, iso_z, new_id, utcnow +from .run import RecoveryAction, ResourceLock, Run, RunEvent, RunStep +from .workflow import Workflow + +__all__ = [ + "Base", + "utcnow", + "new_id", + "iso_z", + # 资产 + "TestHost", + "OobController", + "Dut", + "FirmwareArtifact", + # 工作流 + "Workflow", + # Run + "Run", + "RunStep", + "RunEvent", + "RecoveryAction", + "ResourceLock", + # 分析 + "FailureSignature", + "RunFailure", + "EvidenceBundle", + "AuditLog", +] diff --git a/flashops/services/control-plane/flashops_control/models/analysis.py b/flashops/services/control-plane/flashops_control/models/analysis.py new file mode 100644 index 0000000..462b611 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/models/analysis.py @@ -0,0 +1,112 @@ +"""失败签名、失败实例、Evidence Bundle、审计。""" +from __future__ import annotations + +import datetime as _dt +from typing import Any, Dict, List, Optional + +from sqlalchemy import ( + DateTime, + Float, + ForeignKey, + Index, + Integer, + JSON, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column + +from ..enums import FailureClass, IntegrityState +from .base import Base, TimestampMixin, pk + + +class FailureSignature(Base, TimestampMixin): + """失败签名 —— 聚类锚点。 + + 8 要素哈希(方案 §8.3)。只用错误码会把"掉盘"和"脚本超时"归成一类, + 聚类就废了;用原始日志则每条日志都是新签名。所以用模板化后的日志指纹。 + """ + + __tablename__ = "failure_signatures" + __table_args__ = (UniqueConstraint("hash", name="uq_signature_hash"),) + + id: Mapped[str] = pk("sig") + hash: Mapped[str] = mapped_column(String(64), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + + # 8 要素 + workflow_step: Mapped[str] = mapped_column(String(64), nullable=False) + error_codes: Mapped[List[Any]] = mapped_column(JSON, default=list) + log_templates: Mapped[List[Any]] = mapped_column(JSON, default=list) + host_state: Mapped[str] = mapped_column(String(32), default="UNKNOWN") + dut_enumeration_state: Mapped[str] = mapped_column(String(32), default="UNKNOWN") + data_integrity_state: Mapped[str] = mapped_column(String(16), default=IntegrityState.NOT_CHECKED.value) + recovery_outcome: Mapped[Optional[str]] = mapped_column(String(16)) + environment_fingerprint_hash: Mapped[Optional[str]] = mapped_column(String(64)) + + failure_class: Mapped[str] = mapped_column(String(24), default=FailureClass.UNKNOWN.value) + occurrences: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + first_seen_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + last_seen_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + + # 人工确认后的处置:如已提单号、已知问题、误报 + triage_note: Mapped[Optional[str]] = mapped_column(Text) + issue_ref: Mapped[Optional[str]] = mapped_column(String(64)) + + +class RunFailure(Base): + """一次具体的失败实例,挂在签名下面。""" + + __tablename__ = "run_failures" + __table_args__ = (Index("ix_failure_run", "run_id"),) + + id: Mapped[str] = pk("fail") + run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False) + signature_id: Mapped[Optional[str]] = mapped_column(ForeignKey("failure_signatures.id")) + step_id: Mapped[Optional[str]] = mapped_column(String(32)) + loop_index: Mapped[Optional[int]] = mapped_column(Integer) + ts: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False) + + failure_class: Mapped[str] = mapped_column(String(24), default=FailureClass.UNKNOWN.value) + summary: Mapped[str] = mapped_column(Text, nullable=False) + detail: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + # 记录固件版本,A/B 对比直接从这里聚合 + firmware_label: Mapped[Optional[str]] = mapped_column(String(64)) + + +class EvidenceBundle(Base, TimestampMixin): + """证据包。completeness < 1 说明有关键字段缺失 —— MVP 硬指标是 ≥95%。""" + + __tablename__ = "evidence_bundles" + + id: Mapped[str] = pk("bundle") + run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False) + uri: Mapped[str] = mapped_column(String(512), nullable=False) + manifest: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + size_bytes: Mapped[int] = mapped_column(Integer, default=0) + completeness: Mapped[float] = mapped_column(Float, default=0.0) + missing_fields: Mapped[List[Any]] = mapped_column(JSON, default=list) + + +class AuditLog(Base): + """审计:谁、什么时候、用哪个模板版本、对什么、做了什么、结果如何。 + + 方案 §6.6 的硬要求。与决策放在同一处 —— 见 ADR-0004。 + """ + + __tablename__ = "audit_log" + __table_args__ = (Index("ix_audit_ts", "ts"),) + + id: Mapped[str] = pk("audit") + ts: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False) + actor: Mapped[str] = mapped_column(String(64), nullable=False) + action: Mapped[str] = mapped_column(String(64), nullable=False) + target_type: Mapped[Optional[str]] = mapped_column(String(32)) + target_id: Mapped[Optional[str]] = mapped_column(String(32)) + command_template: Mapped[Optional[str]] = mapped_column(String(64)) + template_version: Mapped[Optional[str]] = mapped_column(String(16)) + params: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + result: Mapped[Optional[str]] = mapped_column(String(32)) + approved_by: Mapped[Optional[str]] = mapped_column(String(64)) + reason: Mapped[Optional[str]] = mapped_column(Text) diff --git a/flashops/services/control-plane/flashops_control/models/assets.py b/flashops/services/control-plane/flashops_control/models/assets.py new file mode 100644 index 0000000..d75dcc4 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/models/assets.py @@ -0,0 +1,111 @@ +"""资产:测试主机、带外控制器、DUT、固件包。""" +from __future__ import annotations + +import datetime as _dt +from typing import Any, Dict, List, Optional + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from ..enums import DutStatus, HostStatus, PowerState +from .base import Base, TimestampMixin, pk + + +class OobController(Base, TimestampMixin): + """带外控制器。 + + 独立成表而不是挂在 TestHost 上,因为它的核心价值就是**主机死后独活**: + 它有自己的网络、自己的心跳、自己的生命周期。主机 OFFLINE 时它必须还 ONLINE, + 这个"矛盾"状态是恢复阶梯 L3-L5 可用性的判断依据。 + """ + + __tablename__ = "oob_controllers" + + id: Mapped[str] = pk("oob") + name: Mapped[str] = mapped_column(String(128), nullable=False) + kind: Mapped[str] = mapped_column(String(32), default="simulated") # raspberry_pi / jetkvm / pdu / simulated + endpoint: Mapped[Optional[str]] = mapped_column(String(255)) + # 能力位:{"power": true, "reset": true, "atx": true, "ac": true, "hdmi": true, "temp": true} + capabilities: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + + status: Mapped[str] = mapped_column(String(16), default=HostStatus.UNKNOWN.value) + power_state: Mapped[str] = mapped_column(String(16), default=PowerState.UNKNOWN.value) + last_seen_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + temperature_c: Mapped[Optional[float]] = mapped_column() + + +class TestHost(Base, TimestampMixin): + """测试主机。环境指纹的主要来源 —— A/B 对比的可比性靠它固定。""" + + __tablename__ = "test_hosts" + + id: Mapped[str] = pk("host") + name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) + + os_family: Mapped[str] = mapped_column(String(32), default="linux") # linux / windows + os_version: Mapped[Optional[str]] = mapped_column(String(128)) + kernel: Mapped[Optional[str]] = mapped_column(String(128)) + cpu: Mapped[Optional[str]] = mapped_column(String(128)) + motherboard: Mapped[Optional[str]] = mapped_column(String(128)) + bios_version: Mapped[Optional[str]] = mapped_column(String(64)) + agent_version: Mapped[Optional[str]] = mapped_column(String(32)) + ip_address: Mapped[Optional[str]] = mapped_column(String(64)) + + status: Mapped[str] = mapped_column(String(16), default=HostStatus.UNKNOWN.value) + last_heartbeat_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + agent_id: Mapped[Optional[str]] = mapped_column(String(32)) + # 只保存 bearer token 的 SHA-256 摘要;原始 token 仅在注册响应中返回一次。 + agent_token: Mapped[Optional[str]] = mapped_column(String(64)) + + oob_controller_id: Mapped[Optional[str]] = mapped_column(ForeignKey("oob_controllers.id")) + # 已安装工具与版本,来自 Agent 的 discover():{"nvme-cli": "2.8", "fio": "3.36"} + toolchain: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + labels: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + + +class Dut(Base, TimestampMixin): + """被测设备。 + + allow_destructive 是防误盘的第二道信号(第一道是序列号,第三道是系统盘检测)。 + 它不给普通 API 改 —— 只能走带审批记录的资产接口,见 ADR-0004。 + """ + + __tablename__ = "duts" + __table_args__ = (UniqueConstraint("serial", name="uq_dut_serial"),) + + id: Mapped[str] = pk("dut") + serial: Mapped[str] = mapped_column(String(64), nullable=False) + model: Mapped[str] = mapped_column(String(128), nullable=False) + vendor: Mapped[Optional[str]] = mapped_column(String(64)) + capacity_gb: Mapped[Optional[int]] = mapped_column(Integer) + form_factor: Mapped[str] = mapped_column(String(32), default="M.2") # M.2 / U.2 / E1.S + interface: Mapped[str] = mapped_column(String(32), default="NVMe") + + current_firmware: Mapped[Optional[str]] = mapped_column(String(64)) + bdf: Mapped[Optional[str]] = mapped_column(String(32)) # PCIe 地址,如 0000:03:00.0 + device_path: Mapped[Optional[str]] = mapped_column(String(64)) # /dev/nvme0n1 + + test_host_id: Mapped[Optional[str]] = mapped_column(ForeignKey("test_hosts.id")) + status: Mapped[str] = mapped_column(String(16), default=DutStatus.IDLE.value) + + # 破坏性测试白名单标记。默认 False —— 安全默认拒绝。 + allow_destructive: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + health: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) # SMART 关键项、温度、寿命 + labels: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + + +class FirmwareArtifact(Base, TimestampMixin): + """固件包。哈希 + 签名 + 适用型号,缺一样就不可审计。""" + + __tablename__ = "firmware_artifacts" + + id: Mapped[str] = pk("fw") + version: Mapped[str] = mapped_column(String(64), nullable=False) + label: Mapped[Optional[str]] = mapped_column(String(64)) # "A" / "B" / "候选版" + sha256: Mapped[str] = mapped_column(String(64), nullable=False) + signature: Mapped[Optional[str]] = mapped_column(String(512)) + applicable_models: Mapped[List[Any]] = mapped_column(JSON, default=list) + flash_tool: Mapped[str] = mapped_column(String(64), default="nvme_cli") + file_uri: Mapped[Optional[str]] = mapped_column(String(512)) + uploaded_by: Mapped[Optional[str]] = mapped_column(String(64)) diff --git a/flashops/services/control-plane/flashops_control/models/base.py b/flashops/services/control-plane/flashops_control/models/base.py new file mode 100644 index 0000000..e4a592d --- /dev/null +++ b/flashops/services/control-plane/flashops_control/models/base.py @@ -0,0 +1,64 @@ +"""ORM 基类与通用字段。 + +刻意保持"贫血模型":这些类只管持久化,不放业务逻辑。 +业务逻辑在 engine/(纯函数)与 services/(编排),这样才能不起数据库单测。 +""" +from __future__ import annotations + +import datetime as _dt +import uuid +from typing import Any, Dict + +from sqlalchemy import DateTime, JSON, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """所有表的基类。 + + type_annotation_map 里只用方言中立的类型:SQLite(开发)与 PostgreSQL(生产) + 行为一致。不要引入 JSONB / ARRAY / UUID 这些 PG 特有类型 —— 见 ADR-0001。 + """ + + type_annotation_map = { + Dict[str, Any]: JSON, + dict: JSON, + list: JSON, + } + + +def utcnow() -> _dt.datetime: + """统一的当前时间:naive UTC。 + + SQLite 不保存时区,所以全仓库统一存 naive UTC,序列化时再补 Z。 + 禁止在任何地方用 datetime.now()(本地时区)—— 实验室里跨时区协作会出事。 + """ + return _dt.datetime.utcnow() + + +def new_id(prefix: str) -> str: + """带前缀的 ID,如 run_7f3a9c2e...。 + + 前缀让日志和时间线一眼可读 —— 排障时不用回表查这个 ID 是什么东西。 + """ + return "{0}_{1}".format(prefix, uuid.uuid4().hex[:20]) + + +def iso_z(value: Any) -> Any: + """naive UTC datetime → ISO8601 带 Z。非 datetime 原样返回。""" + if isinstance(value, _dt.datetime): + return value.replace(microsecond=value.microsecond).isoformat() + "Z" + return value + + +class TimestampMixin: + created_at: Mapped[_dt.datetime] = mapped_column(DateTime, default=utcnow, nullable=False) + updated_at: Mapped[_dt.datetime] = mapped_column( + DateTime, default=utcnow, onupdate=utcnow, nullable=False + ) + + +def pk(prefix: str) -> Mapped[str]: + return mapped_column( + String(32), primary_key=True, default=lambda: new_id(prefix) + ) diff --git a/flashops/services/control-plane/flashops_control/models/run.py b/flashops/services/control-plane/flashops_control/models/run.py new file mode 100644 index 0000000..2bed032 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/models/run.py @@ -0,0 +1,188 @@ +"""Run 及其行为记录。 + +runs / run_steps / recovery_actions 是**读模型**(投影)。 +唯一真相是 run_events(append-only)—— 见 ADR-0002。 + +仓储层刻意不提供 update_run_state():改状态的唯一入口是 events/recorder.py。 +""" +from __future__ import annotations + +import datetime as _dt +from typing import Any, Dict, List, Optional + +from sqlalchemy import ( + Boolean, + DateTime, + Float, + ForeignKey, + Index, + Integer, + JSON, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column + +from ..enums import EventSource, RunState, Severity, StepState +from .base import Base, TimestampMixin, pk + + +class Run(Base, TimestampMixin): + __tablename__ = "runs" + + id: Mapped[str] = pk("run") + name: Mapped[Optional[str]] = mapped_column(String(255)) + + workflow_id: Mapped[str] = mapped_column(ForeignKey("workflows.id"), nullable=False) + workflow_key: Mapped[str] = mapped_column(String(64), nullable=False) + workflow_version: Mapped[int] = mapped_column(Integer, nullable=False) + spec_hash: Mapped[str] = mapped_column(String(64), nullable=False) + + state: Mapped[str] = mapped_column(String(16), default=RunState.QUEUED.value, nullable=False) + verdict: Mapped[Optional[str]] = mapped_column(String(16)) + + dut_id: Mapped[Optional[str]] = mapped_column(ForeignKey("duts.id")) + test_host_id: Mapped[Optional[str]] = mapped_column(ForeignKey("test_hosts.id")) + firmware_a_id: Mapped[Optional[str]] = mapped_column(ForeignKey("firmware_artifacts.id")) + firmware_b_id: Mapped[Optional[str]] = mapped_column(ForeignKey("firmware_artifacts.id")) + + params: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + # 任务开始时冻结的不可变快照。固件之外任何一项变了,A/B 对比结论就不成立。 + env_fingerprint: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + env_fingerprint_hash: Mapped[Optional[str]] = mapped_column(String(64)) + + loop_target: Mapped[int] = mapped_column(Integer, default=1) + loop_done: Mapped[int] = mapped_column(Integer, default=0) + + # 最近一次检查点:{"loop_index": 37, "next_step_key": "...", "dut_fw": "A"} + checkpoint: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + + started_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + ended_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + + # ROI 凭据(方案 §9.3):这两个字段是销售武器,从第一行代码就记。 + unattended_completion: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + human_touches: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + recovery_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + # 事件序号分配器(单点递增,保证时间线可重放,见 ADR-0002) + event_seq: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + created_by: Mapped[Optional[str]] = mapped_column(String(64)) + freeze_reason: Mapped[Optional[str]] = mapped_column(Text) + + +class RunStep(Base, TimestampMixin): + """物化后的步骤实例。循环体在物化时按 loop_index 展开。""" + + __tablename__ = "run_steps" + __table_args__ = ( + UniqueConstraint("run_id", "seq", name="uq_step_run_seq"), + Index("ix_step_run_state", "run_id", "state"), + ) + + id: Mapped[str] = pk("step") + run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False) + seq: Mapped[int] = mapped_column(Integer, nullable=False) + loop_index: Mapped[Optional[int]] = mapped_column(Integer) + + step_key: Mapped[str] = mapped_column(String(64), nullable=False) + step_type: Mapped[str] = mapped_column(String(32), nullable=False) + adapter: Mapped[str] = mapped_column(String(32), nullable=False) + template: Mapped[Optional[str]] = mapped_column(String(64)) + params: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + + state: Mapped[str] = mapped_column(String(16), default=StepState.PENDING.value, nullable=False) + attempt: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + max_retry: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + timeout_s: Mapped[float] = mapped_column(Float, default=600.0, nullable=False) + + # 非幂等步骤(默认)中断后不自动重跑 —— "自动重试把盘刷坏"是最容易砸招牌的失败模式 + idempotent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + danger: Mapped[str] = mapped_column(String(16), default="none") + on_fail: Mapped[str] = mapped_column(String(16), default="retry") + checkpoint_after: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # 租约:Agent 领了活就死了 → 租约到期 → 步骤回 PENDING 重新派发 + leased_by: Mapped[Optional[str]] = mapped_column(String(32)) + lease_expires_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + + started_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + ended_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + exit_code: Mapped[Optional[int]] = mapped_column(Integer) + error_class: Mapped[Optional[str]] = mapped_column(String(32)) + result: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) # UnifiedResult + log_uri: Mapped[Optional[str]] = mapped_column(String(512)) + + +class RunEvent(Base): + """Append-only 事件流 —— 唯一真相。 + + seq 由控制平面单点分配(不按时间戳排序):主机断电、时钟漂移、 + 带外与主机时钟不同步都会让时间戳乱序。Agent 事件与带外事件进同一条序列, + 双通道观测的交叉校验靠它。 + """ + + __tablename__ = "run_events" + __table_args__ = ( + UniqueConstraint("run_id", "seq", name="uq_event_run_seq"), + # 网络抖动重发不产生重复事件(见 agent-protocol.md) + UniqueConstraint("run_id", "idempotency_key", name="uq_event_idem"), + Index("ix_event_run_seq", "run_id", "seq"), + ) + + id: Mapped[str] = pk("ev") + run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False) + seq: Mapped[int] = mapped_column(Integer, nullable=False) + ts: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False) + + source: Mapped[str] = mapped_column(String(16), default=EventSource.CONTROL_PLANE.value) + kind: Mapped[str] = mapped_column(String(48), nullable=False) + severity: Mapped[str] = mapped_column(String(16), default=Severity.INFO.value) + + step_id: Mapped[Optional[str]] = mapped_column(String(32)) + loop_index: Mapped[Optional[int]] = mapped_column(Integer) + message: Mapped[Optional[str]] = mapped_column(Text) + payload: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + + idempotency_key: Mapped[Optional[str]] = mapped_column(String(64)) + + +class RecoveryAction(Base): + """每一级恢复的留证记录。恢复前抓画面/温度,恢复后验证 DUT 重新枚举。""" + + __tablename__ = "recovery_actions" + + id: Mapped[str] = pk("rec") + run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False) + step_id: Mapped[Optional[str]] = mapped_column(String(32)) + loop_index: Mapped[Optional[int]] = mapped_column(Integer) + + level: Mapped[str] = mapped_column(String(24), nullable=False) + trigger: Mapped[str] = mapped_column(String(32), nullable=False) + outcome: Mapped[Optional[str]] = mapped_column(String(16)) + + started_at: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False) + ended_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) + detail: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) + evidence_uris: Mapped[List[Any]] = mapped_column(JSON, default=list) + + +class ResourceLock(Base): + """DUT / 主机独占锁。PREFLIGHT 获取,终态释放。 + + 唯一约束在 (resource_type, resource_id) —— 数据库来保证互斥,不靠应用层自觉。 + """ + + __tablename__ = "resource_locks" + __table_args__ = ( + UniqueConstraint("resource_type", "resource_id", name="uq_lock_resource"), + ) + + id: Mapped[str] = pk("lock") + resource_type: Mapped[str] = mapped_column(String(16), nullable=False) # dut / test_host / oob + resource_id: Mapped[str] = mapped_column(String(32), nullable=False) + run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False) + acquired_at: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False) + expires_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime) diff --git a/flashops/services/control-plane/flashops_control/models/workflow.py b/flashops/services/control-plane/flashops_control/models/workflow.py new file mode 100644 index 0000000..08b42e7 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/models/workflow.py @@ -0,0 +1,35 @@ +"""工作流:SOP 的可执行形态。""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from sqlalchemy import Boolean, Integer, JSON, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from ..enums import DangerLevel +from .base import Base, TimestampMixin, pk + + +class Workflow(Base, TimestampMixin): + """工作流定义。 + + spec 存原文,spec_hash 存规范化后的哈希。spec_hash 变了而 version 没变 → + 拒绝发布(见 workflow-spec.md)。理由:A/B 对比的结论必须能追溯到 + 具体哪一版流程,否则"上周跑的和这周跑的是不是同一个流程"说不清。 + """ + + __tablename__ = "workflows" + __table_args__ = (UniqueConstraint("key", "version", name="uq_workflow_key_version"),) + + id: Mapped[str] = pk("wf") + key: Mapped[str] = mapped_column(String(64), nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + + spec: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False) + spec_hash: Mapped[str] = mapped_column(String(64), nullable=False) + + danger_level: Mapped[str] = mapped_column(String(16), default=DangerLevel.NONE.value) + source_sop: Mapped[Optional[str]] = mapped_column(String(255)) + published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + approved_by: Mapped[Optional[str]] = mapped_column(String(64)) diff --git a/flashops/services/control-plane/flashops_control/safety/__init__.py b/flashops/services/control-plane/flashops_control/safety/__init__.py new file mode 100644 index 0000000..609c544 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/safety/__init__.py @@ -0,0 +1,5 @@ +"""危险动作的安全门禁。""" + +from .gates import PreflightRequest, PreflightResult, run_preflight + +__all__ = ["PreflightRequest", "PreflightResult", "run_preflight"] diff --git a/flashops/services/control-plane/flashops_control/safety/gates.py b/flashops/services/control-plane/flashops_control/safety/gates.py new file mode 100644 index 0000000..edf095c --- /dev/null +++ b/flashops/services/control-plane/flashops_control/safety/gates.py @@ -0,0 +1,90 @@ +"""安全默认拒绝的预检规则。""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import PurePath +from typing import Dict, List, Sequence + + +@dataclass(frozen=True) +class Check: + key: str + name: str + passed: bool + note: str + + +@dataclass(frozen=True) +class PreflightRequest: + expected_serial: str + discovered_serial: str + allow_destructive: bool + device_path: str + system_device_paths: Sequence[str] + firmware_model_allowed: bool + host_online: bool + oob_online: bool + + +@dataclass(frozen=True) +class PreflightResult: + passed: bool + checks: List[Check] + + def as_dict(self) -> Dict[str, object]: + return { + "passed": self.passed, + "checks": [asdict(check) for check in self.checks], + } + + +def _same_device(left: str, right: str) -> bool: + left_name = PurePath(left).name + right_name = PurePath(right).name + if left_name == right_name: + return True + # /dev/nvme0n1p2 属于 /dev/nvme0n1;拒绝把其任一分区当作测试盘。 + return left_name.startswith(right_name + "p") or right_name.startswith(left_name + "p") + + +def run_preflight(request: PreflightRequest) -> PreflightResult: + serial_ok = bool(request.expected_serial) and ( + request.expected_serial == request.discovered_serial + ) + system_disk_safe = bool(request.device_path) and not any( + _same_device(request.device_path, path) for path in request.system_device_paths + ) + checks = [ + Check("serial", "DUT 序列号白名单绑定", serial_ok, request.discovered_serial or "未发现"), + Check( + "destructive", + "破坏性写入授权", + request.allow_destructive, + "已审批" if request.allow_destructive else "DUT 未授权破坏性写入", + ), + Check( + "system_disk", + "系统盘 / 分区保护", + system_disk_safe, + "启动盘已排除" if system_disk_safe else "目标命中系统盘或其分区", + ), + Check( + "firmware", + "固件适配型号", + request.firmware_model_allowed, + "型号匹配" if request.firmware_model_allowed else "固件不适用于该型号", + ), + Check( + "host", + "测试主机与 Agent", + request.host_online, + "在线" if request.host_online else "主机离线", + ), + Check( + "oob", + "带外控制器", + request.oob_online, + "Power / Reset 可用" if request.oob_online else "带外通道离线", + ), + ] + return PreflightResult(all(check.passed for check in checks), checks) diff --git a/flashops/services/control-plane/flashops_control/schemas.py b/flashops/services/control-plane/flashops_control/schemas.py new file mode 100644 index 0000000..9652baf --- /dev/null +++ b/flashops/services/control-plane/flashops_control/schemas.py @@ -0,0 +1,80 @@ +"""HTTP API 的 Pydantic 契约。""" +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field + + +class PreflightBody(BaseModel): + dut_id: Optional[str] = None + host_id: Optional[str] = None + firmware_id: Optional[str] = None + discovered_serial: Optional[str] = None + + +class CreateRunBody(BaseModel): + name: str = "固件 A/B 无人值守回归" + workflow_id: Optional[str] = None + dut_id: Optional[str] = None + host_id: Optional[str] = None + firmware_a_id: Optional[str] = None + firmware_b_id: Optional[str] = None + loops: int = Field(default=8, ge=1, le=500) + inject_failure: bool = True + execution_mode: Literal["simulated", "agent_dry_run"] = "simulated" + created_by: str = "console-demo" + + +class HumanActionBody(BaseModel): + actor: str = "console-user" + reason: str = "人工操作" + + +class AgentRegisterBody(BaseModel): + host_id: Optional[str] = None + host_name: Optional[str] = None + agent_version: str = Field(min_length=1, max_length=32) + os_family: str = Field(default="linux", min_length=1, max_length=32) + os_version: Optional[str] = Field(default=None, max_length=128) + kernel: Optional[str] = Field(default=None, max_length=128) + cpu: Optional[str] = Field(default=None, max_length=128) + motherboard: Optional[str] = Field(default=None, max_length=128) + bios_version: Optional[str] = Field(default=None, max_length=64) + ip_address: Optional[str] = Field(default=None, max_length=64) + toolchain: Dict[str, Any] = Field(default_factory=dict) + labels: Dict[str, Any] = Field(default_factory=dict) + + +class AgentHeartbeatBody(BaseModel): + healthy: bool = True + agent_version: Optional[str] = Field(default=None, max_length=32) + ip_address: Optional[str] = Field(default=None, max_length=64) + toolchain: Dict[str, Any] = Field(default_factory=dict) + device_probe: Dict[str, Any] = Field(default_factory=dict) + + +class AgentLeaseBody(BaseModel): + wait_timeout_s: float = Field(default=0.0, ge=0.0, le=20.0) + + +class AgentStepAttemptBody(BaseModel): + attempt: int = Field(ge=1) + + +class AgentStepEvent(BaseModel): + message: str = Field(min_length=1, max_length=2000) + severity: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + payload: Dict[str, Any] = Field(default_factory=dict) + + +class AgentStepEventsBody(AgentStepAttemptBody): + events: List[AgentStepEvent] = Field(min_length=1, max_length=100) + + +class AgentStepCompleteBody(AgentStepAttemptBody): + status: Literal["SUCCEEDED", "FAILED"] + exit_code: Optional[int] = None + error_class: Optional[str] = Field(default=None, max_length=32) + result: Dict[str, Any] = Field(default_factory=dict) + log_uri: Optional[str] = Field(default=None, max_length=512) diff --git a/flashops/services/control-plane/flashops_control/seed.py b/flashops/services/control-plane/flashops_control/seed.py new file mode 100644 index 0000000..31eb893 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/seed.py @@ -0,0 +1,141 @@ +"""可重复执行的演示数据。""" +from __future__ import annotations + +import hashlib +import json + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from .enums import DutStatus, HostStatus, PowerState +from .models import Dut, FirmwareArtifact, OobController, TestHost, Workflow, utcnow + + +def _spec_hash(spec: dict) -> str: + raw = json.dumps(spec, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +async def ensure_seed_data(session: AsyncSession) -> None: + existing = await session.scalar(select(Workflow.id).limit(1)) + if existing: + return + + oob = OobController( + name="OOB-05", + kind="simulated", + endpoint="sim://oob-05", + capabilities={"power": True, "reset": True, "atx": True, "ac": True, "hdmi": True}, + status=HostStatus.ONLINE.value, + power_state=PowerState.ON.value, + last_seen_at=utcnow(), + temperature_c=42.6, + ) + session.add(oob) + await session.flush() + + host = TestHost( + name="WS-05", + os_family="linux", + os_version="Ubuntu 22.04.4 LTS", + kernel="6.8.0-40-generic", + cpu="Intel Core i7-13700", + motherboard="FlashOps SIM-X1", + bios_version="F26b", + agent_version="0.1.0-sim", + ip_address="192.0.2.15", + status=HostStatus.ONLINE.value, + last_heartbeat_at=utcnow(), + oob_controller_id=oob.id, + toolchain={"fio": "3.36", "nvme-cli": "2.9.1"}, + labels={"system_device_paths": ["/dev/nvme0n1"]}, + ) + session.add(host) + await session.flush() + + dut = Dut( + serial="S6XNNA0T609", + model="NV-E1T92", + vendor="Lab Sample", + capacity_gb=1920, + form_factor="U.2", + current_firmware="3.2.1", + bdf="0000:03:00.0", + device_path="/dev/nvme1n1", + test_host_id=host.id, + status=DutStatus.IDLE.value, + allow_destructive=True, + health={"temperature_c": 43, "percentage_used": 12, "critical_warning": 0}, + labels={"purpose": "test-only"}, + ) + session.add(dut) + + firmware_a = FirmwareArtifact( + version="3.2.1", + label="A / 基线", + sha256="4bd1" + "0" * 56 + "08ce", + signature="simulated-valid-signature", + applicable_models=[dut.model], + file_uri="sim://firmware/FW_3.2.1.bin", + uploaded_by="seed", + ) + firmware_b = FirmwareArtifact( + version="3.3.0-rc3", + label="B / 候选", + sha256="9f2c" + "0" * 56 + "41aa", + signature="simulated-valid-signature", + applicable_models=[dut.model], + file_uri="sim://firmware/FW_3.3.0-rc3.bin", + uploaded_by="seed", + ) + session.add_all([firmware_a, firmware_b]) + + spec = { + "key": "fw-ab-regression", + "name": "固件 A/B 电源状态回归", + "version": 3, + "danger_level": "high", + "params": {"loops": {"type": "int", "default": 8, "min": 1, "max": 10000}}, + "steps": [ + {"key": "preflight", "type": "precheck", "adapter": "safety"}, + { + "key": "flash-fw-b", + "type": "command", + "adapter": "nvme_cli", + "template": "nvme_fw_download_commit", + "danger": "high", + "idempotent": False, + "checkpoint": True, + }, + { + "key": "regression-loop", + "type": "loop", + "body": [ + { + "key": "workload", + "type": "command", + "adapter": "fio", + "idempotent": True, + "retry": 1, + }, + {"key": "enumeration-check", "type": "device_check", "adapter": "nvme_cli"}, + {"key": "integrity-check", "type": "command", "adapter": "shell"}, + ], + "checkpoint": True, + }, + {"key": "report", "type": "report", "adapter": "builtin"}, + ], + } + workflow = Workflow( + key=spec["key"], + name=spec["name"], + version=spec["version"], + spec=spec, + spec_hash=_spec_hash(spec), + danger_level="high", + source_sop="内置模拟 SOP v1", + published=True, + approved_by="system-seed", + ) + session.add(workflow) + await session.flush() diff --git a/flashops/services/control-plane/flashops_control/services/__init__.py b/flashops/services/control-plane/flashops_control/services/__init__.py new file mode 100644 index 0000000..a2a0787 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/services/__init__.py @@ -0,0 +1 @@ +"""应用服务。""" diff --git a/flashops/services/control-plane/flashops_control/services/agent_status.py b/flashops/services/control-plane/flashops_control/services/agent_status.py new file mode 100644 index 0000000..c75802e --- /dev/null +++ b/flashops/services/control-plane/flashops_control/services/agent_status.py @@ -0,0 +1,95 @@ +"""Agent heartbeat aging and Host status projection.""" +from __future__ import annotations + +import asyncio +import datetime as dt +import logging +from contextlib import suppress +from typing import Optional + +from sqlalchemy import select + +from ..config import TimingPolicy, get_settings +from ..db import session_scope +from ..enums import HostStatus +from ..models import TestHost, utcnow + +logger = logging.getLogger(__name__) + + +def projected_agent_status( + last_heartbeat_at: Optional[dt.datetime], + current_status: str, + *, + now: Optional[dt.datetime] = None, + timing: Optional[TimingPolicy] = None, +) -> str: + """Return the status implied by heartbeat age. + + A fresh explicit DEGRADED report remains degraded until the Agent sends a + healthy heartbeat. Only Agents with an ``agent_id`` use this projection; + seeded simulator Hosts retain their configured status. + """ + + if last_heartbeat_at is None: + return HostStatus.OFFLINE.value + policy = timing or get_settings().timing + age_s = max(0.0, ((now or utcnow()) - last_heartbeat_at).total_seconds()) + if age_s >= policy.heartbeat_lost_after_s: + return HostStatus.OFFLINE.value + if age_s >= policy.heartbeat_degraded_after_s: + return HostStatus.DEGRADED.value + if current_status == HostStatus.DEGRADED.value: + return HostStatus.DEGRADED.value + return HostStatus.ONLINE.value + + +def refresh_agent_host(host: TestHost, *, now: Optional[dt.datetime] = None) -> str: + """Refresh one registered Host in the caller's transaction.""" + + if not host.agent_id: + return host.status + host.status = projected_agent_status( + host.last_heartbeat_at, + host.status, + now=now, + ) + return host.status + + +async def refresh_all_agent_hosts(*, now: Optional[dt.datetime] = None) -> int: + """Persist heartbeat-derived states for all registered Agents.""" + + changed = 0 + async with session_scope() as session: + hosts = ( + await session.scalars( + select(TestHost).where(TestHost.agent_id.is_not(None)) + ) + ).all() + for host in hosts: + previous = host.status + refresh_agent_host(host, now=now) + if host.status != previous: + changed += 1 + return changed + + +async def run_agent_watchdog() -> None: + """Continuously age registered Agent heartbeats.""" + + interval_s = max(1.0, get_settings().timing.heartbeat_interval_s) + while True: + try: + await refresh_all_agent_hosts() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Agent heartbeat watchdog failed") + await asyncio.sleep(interval_s) + + +async def stop_agent_watchdog(task: "asyncio.Task[None]") -> None: + task.cancel() + with suppress(asyncio.CancelledError): + await task diff --git a/flashops/services/control-plane/flashops_control/services/runs.py b/flashops/services/control-plane/flashops_control/services/runs.py new file mode 100644 index 0000000..6381b79 --- /dev/null +++ b/flashops/services/control-plane/flashops_control/services/runs.py @@ -0,0 +1,560 @@ +"""Run 创建、查询与内置模拟执行器。""" +from __future__ import annotations + +import asyncio +import hashlib +import json +from typing import Any, Dict, Optional, Set + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config import get_settings +from ..db import session_scope +from ..enums import ( + EventKind, + EventSource, + HostStatus, + RecoveryLevel, + RecoveryOutcome, + RecoveryTrigger, + RunState, + Severity, + Verdict, +) +from ..events import append_event +from ..evidence import build_evidence_bundle +from ..engine.planner import materialize_run_steps +from ..models import ( + Dut, + FirmwareArtifact, + OobController, + RecoveryAction, + ResourceLock, + Run, + RunEvent, + TestHost, + Workflow, + iso_z, + utcnow, +) +from ..safety import PreflightRequest, run_preflight +from ..schemas import CreateRunBody, PreflightBody +from .agent_status import refresh_agent_host + +_tasks: Dict[str, asyncio.Task[None]] = {} +_task_refs: Set[asyncio.Task[None]] = set() + + +async def _first(session: AsyncSession, model: Any) -> Any: + value = await session.scalar(select(model).limit(1)) + if value is None: + raise LookupError("演示数据尚未初始化") + return value + + +async def resolve_assets( + session: AsyncSession, + *, + dut_id: Optional[str] = None, + host_id: Optional[str] = None, + firmware_id: Optional[str] = None, +) -> tuple[Dut, TestHost, Optional[FirmwareArtifact], Optional[OobController]]: + dut = await session.get(Dut, dut_id) if dut_id else await _first(session, Dut) + host = await session.get(TestHost, host_id) if host_id else await _first(session, TestHost) + firmware = ( + await session.get(FirmwareArtifact, firmware_id) + if firmware_id + else await _first(session, FirmwareArtifact) + ) + if dut is None or host is None: + raise LookupError("DUT 或测试主机不存在") + oob = await session.get(OobController, host.oob_controller_id) if host.oob_controller_id else None + return dut, host, firmware, oob + + +async def preflight_for_body(session: AsyncSession, body: PreflightBody) -> Dict[str, object]: + dut, host, firmware, oob = await resolve_assets( + session, + dut_id=body.dut_id, + host_id=body.host_id, + firmware_id=body.firmware_id, + ) + refresh_agent_host(host) + system_paths = host.labels.get("system_device_paths", []) if host.labels else [] + allowed = firmware is not None and ( + not firmware.applicable_models or dut.model in firmware.applicable_models + ) + result = run_preflight( + PreflightRequest( + expected_serial=dut.serial, + discovered_serial=body.discovered_serial or dut.serial, + allow_destructive=dut.allow_destructive, + device_path=dut.device_path or "", + system_device_paths=system_paths, + firmware_model_allowed=allowed, + host_online=host.status == HostStatus.ONLINE.value, + oob_online=oob is not None and oob.status == HostStatus.ONLINE.value, + ) + ) + payload = result.as_dict() + payload.update({"dut_id": dut.id, "host_id": host.id, "firmware_id": firmware.id if firmware else None}) + return payload + + +async def create_run(session: AsyncSession, body: CreateRunBody) -> Run: + workflow = ( + await session.get(Workflow, body.workflow_id) + if body.workflow_id + else await _first(session, Workflow) + ) + dut, host, _, _ = await resolve_assets( + session, dut_id=body.dut_id, host_id=body.host_id + ) + firmwares = ( + await session.scalars(select(FirmwareArtifact).order_by(FirmwareArtifact.created_at)) + ).all() + if not firmwares: + raise LookupError("固件数据尚未初始化") + firmware_a = ( + await session.get(FirmwareArtifact, body.firmware_a_id) + if body.firmware_a_id + else firmwares[0] + ) + firmware_b = ( + await session.get(FirmwareArtifact, body.firmware_b_id) + if body.firmware_b_id + else firmwares[-1] + ) + assert workflow is not None and firmware_a is not None and firmware_b is not None + env = { + "host": host.name, + "os": host.os_version, + "kernel": host.kernel, + "bios": host.bios_version, + "agent": host.agent_version, + "toolchain": host.toolchain, + "dut_serial": dut.serial, + "dut_model": dut.model, + } + env_hash = hashlib.sha256( + json.dumps(env, ensure_ascii=False, sort_keys=True).encode("utf-8") + ).hexdigest() + run = Run( + name=body.name, + workflow_id=workflow.id, + workflow_key=workflow.key, + workflow_version=workflow.version, + spec_hash=workflow.spec_hash, + dut_id=dut.id, + test_host_id=host.id, + firmware_a_id=firmware_a.id, + firmware_b_id=firmware_b.id, + params={ + "inject_failure": body.inject_failure, + "execution_mode": body.execution_mode, + "loops": body.loops, + "fw_a": firmware_a.id, + "fw_b": firmware_b.id, + "firmware_a_version": firmware_a.version, + "firmware_b_version": firmware_b.version, + }, + env_fingerprint=env, + env_fingerprint_hash=env_hash, + loop_target=body.loops, + created_by=body.created_by, + ) + session.add(run) + await session.flush() + await append_event( + session, + run, + EventKind.RUN_CREATED.value, + "任务已创建,等待独占资源", + payload={"loops": body.loops, "inject_failure": body.inject_failure}, + ) + await append_event( + session, run, EventKind.RUN_QUEUED.value, "任务已进入调度队列" + ) + return run + + +def run_to_dict(run: Run) -> Dict[str, Any]: + progress = round((run.loop_done / run.loop_target) * 100, 1) if run.loop_target else 0 + return { + "id": run.id, + "name": run.name, + "state": run.state, + "verdict": run.verdict, + "workflow_key": run.workflow_key, + "workflow_version": run.workflow_version, + "execution_mode": run.params.get("execution_mode", "simulated"), + "dut_id": run.dut_id, + "test_host_id": run.test_host_id, + "loop_target": run.loop_target, + "loop_done": run.loop_done, + "progress": progress, + "checkpoint": run.checkpoint, + "environment": run.env_fingerprint, + "firmware_a_version": run.params.get("firmware_a_version"), + "firmware_b_version": run.params.get("firmware_b_version"), + "recovery_count": run.recovery_count, + "unattended_completion": run.unattended_completion, + "human_touches": run.human_touches, + "created_at": iso_z(run.created_at), + "started_at": iso_z(run.started_at), + "ended_at": iso_z(run.ended_at), + "freeze_reason": run.freeze_reason, + } + + +async def prepare_agent_run(session: AsyncSession, run: Run) -> None: + """Run safety gates, lock resources and materialize an Agent dry-run.""" + + await append_event( + session, + run, + EventKind.RUN_PREFLIGHT_STARTED.value, + "已锁定执行计划,开始 Agent 任务安全预检", + target_state=RunState.PREFLIGHT.value, + ) + preflight = await preflight_for_body( + session, + PreflightBody( + dut_id=run.dut_id, + host_id=run.test_host_id, + firmware_id=run.firmware_b_id, + ), + ) + if not preflight["passed"]: + await append_event( + session, + run, + EventKind.RUN_REJECTED.value, + "安全预检未通过", + severity=Severity.CRITICAL.value, + payload=preflight, + target_state=RunState.REJECTED.value, + projection={"ended_at": utcnow()}, + ) + return + + existing_lock = await session.scalar( + select(ResourceLock.id).where( + ResourceLock.resource_type.in_(["dut", "test_host"]), + ResourceLock.resource_id.in_([run.dut_id or "", run.test_host_id or ""]), + ) + ) + if existing_lock: + raise LookupError("DUT 或测试主机已被其他任务锁定") + + workflow = await session.get(Workflow, run.workflow_id) + if workflow is None: + raise LookupError("Run 关联工作流不存在") + steps = await materialize_run_steps(session, run, workflow) + session.add_all( + [ + ResourceLock( + resource_type="dut", + resource_id=run.dut_id or "", + run_id=run.id, + acquired_at=utcnow(), + ), + ResourceLock( + resource_type="test_host", + resource_id=run.test_host_id or "", + run_id=run.id, + acquired_at=utcnow(), + ), + ] + ) + await append_event( + session, + run, + EventKind.SAFETY_APPROVED.value, + "6/6 安全门禁通过", + payload=preflight, + ) + await append_event( + session, + run, + EventKind.LOCK_ACQUIRED.value, + "DUT 与测试主机独占锁已获取", + ) + await append_event( + session, + run, + EventKind.RUN_STARTED.value, + "独立 Host Agent dry-run 已就绪,共 {0} 个步骤".format(len(steps)), + payload={"step_count": len(steps), "execution_mode": "agent_dry_run"}, + target_state=RunState.RUNNING.value, + projection={"started_at": utcnow()}, + ) + + +def event_to_dict(event: RunEvent) -> Dict[str, Any]: + return { + "id": event.id, + "seq": event.seq, + "ts": iso_z(event.ts), + "source": event.source, + "kind": event.kind, + "severity": event.severity, + "message": event.message, + "payload": event.payload, + } + + +async def _sleep(seconds: float) -> None: + await asyncio.sleep(max(0.01, seconds * get_settings().time_scale)) + + +async def _load_run(session: AsyncSession, run_id: str) -> Run: + run = await session.get(Run, run_id) + if run is None: + raise LookupError("Run 不存在: {0}".format(run_id)) + return run + + +async def _run_recovery(run_id: str, loop_index: int) -> None: + levels = [ + (RecoveryLevel.AGENT_SOFT, RecoveryOutcome.FAILED, "Agent 软恢复无响应"), + (RecoveryLevel.OS_REBOOT, RecoveryOutcome.FAILED, "OS 重启通道超时"), + (RecoveryLevel.OOB_RESET, RecoveryOutcome.RECOVERED, "带外 Reset 后主机与 DUT 已恢复"), + ] + for level, outcome, message in levels: + async with session_scope() as session: + run = await _load_run(session, run_id) + action = RecoveryAction( + run_id=run.id, + loop_index=loop_index, + level=level.value, + trigger=RecoveryTrigger.HEARTBEAT_LOST.value, + outcome=outcome.value, + started_at=utcnow(), + ended_at=utcnow(), + detail={"simulated": True, "message": message}, + evidence_uris=["sim://oob/frame-{0}".format(loop_index)], + ) + session.add(action) + await append_event( + session, + run, + EventKind.RECOVERY_STARTED.value, + "{0} 已执行".format(level.value), + severity=Severity.WARNING.value, + payload={"level": level.value, "outcome": outcome.value}, + projection={"recovery_count": run.recovery_count + 1}, + ) + if outcome == RecoveryOutcome.RECOVERED: + await append_event( + session, + run, + EventKind.RECOVERY_SUCCEEDED.value, + message, + source=EventSource.OOB.value, + payload={"level": level.value}, + target_state=RunState.RUNNING.value, + ) + else: + await append_event( + session, + run, + EventKind.RECOVERY_ESCALATED.value, + message + ",升级恢复阶梯", + severity=Severity.ERROR.value, + payload={"level": level.value}, + ) + await _sleep(0.28) + + +async def simulate_run(run_id: str) -> None: + try: + async with session_scope() as session: + run = await _load_run(session, run_id) + await append_event( + session, + run, + EventKind.RUN_PREFLIGHT_STARTED.value, + "已锁定工位,开始安全预检", + target_state=RunState.PREFLIGHT.value, + ) + await _sleep(0.25) + + async with session_scope() as session: + run = await _load_run(session, run_id) + preflight = await preflight_for_body( + session, + PreflightBody( + dut_id=run.dut_id, + host_id=run.test_host_id, + firmware_id=run.firmware_b_id, + ), + ) + if not preflight["passed"]: + await append_event( + session, + run, + EventKind.RUN_REJECTED.value, + "安全预检未通过", + severity=Severity.CRITICAL.value, + payload=preflight, + target_state=RunState.REJECTED.value, + projection={"ended_at": utcnow()}, + ) + return + session.add_all( + [ + ResourceLock( + resource_type="dut", + resource_id=run.dut_id or "", + run_id=run.id, + acquired_at=utcnow(), + ), + ResourceLock( + resource_type="test_host", + resource_id=run.test_host_id or "", + run_id=run.id, + acquired_at=utcnow(), + ), + ] + ) + await append_event( + session, + run, + EventKind.SAFETY_APPROVED.value, + "6/6 安全门禁通过", + payload=preflight, + ) + await append_event( + session, + run, + EventKind.LOCK_ACQUIRED.value, + "DUT 与测试主机独占锁已获取", + ) + await append_event( + session, + run, + EventKind.RUN_STARTED.value, + "模拟 Agent 已接管执行", + target_state=RunState.RUNNING.value, + projection={"started_at": utcnow()}, + ) + + async with session_scope() as session: + run = await _load_run(session, run_id) + target = run.loop_target + inject_failure = bool(run.params.get("inject_failure", False)) + failure_loop = min(3, target) if inject_failure and target >= 2 else -1 + + for loop_index in range(1, target + 1): + while True: + async with session_scope() as session: + run = await _load_run(session, run_id) + if run.state != RunState.PAUSED.value: + break + await _sleep(0.2) + async with session_scope() as session: + run = await _load_run(session, run_id) + if run.state in { + RunState.ABORTED.value, + RunState.FROZEN.value, + RunState.REJECTED.value, + }: + return + await append_event( + session, + run, + EventKind.LOOP_STARTED.value, + "循环 {0}/{1} 开始".format(loop_index, target), + payload={"loop_index": loop_index}, + ) + await _sleep(0.22) + + if loop_index == failure_loop: + async with session_scope() as session: + run = await _load_run(session, run_id) + await append_event( + session, + run, + EventKind.HEARTBEAT_LOST.value, + "Agent 心跳丢失;OOB 仍在线,启动自动恢复", + source=EventSource.OOB.value, + severity=Severity.ERROR.value, + payload={"loop_index": loop_index, "oob_online": True}, + target_state=RunState.RECOVERING.value, + ) + await _sleep(0.25) + await _run_recovery(run_id, loop_index) + async with session_scope() as session: + run = await _load_run(session, run_id) + await append_event( + session, + run, + EventKind.CHECKPOINT_RESUMED.value, + "主机指纹一致,从循环检查点续跑", + payload={"loop_index": loop_index}, + ) + + async with session_scope() as session: + run = await _load_run(session, run_id) + checkpoint = { + "loop_index": loop_index, + "next_step_key": "regression-loop", + "dut_fw": "B", + } + await append_event( + session, + run, + EventKind.LOOP_COMPLETED.value, + "循环 {0}/{1} 通过,检查点已写入".format(loop_index, target), + payload={"loop_index": loop_index}, + projection={"loop_done": loop_index, "checkpoint": checkpoint}, + ) + await _sleep(0.14) + + async with session_scope() as session: + run = await _load_run(session, run_id) + bundle = await build_evidence_bundle(session, run) + await append_event( + session, + run, + EventKind.EVIDENCE_BUNDLED.value, + "Evidence Bundle 已生成,完整率 100%", + payload={"bundle_id": bundle.id, "uri": bundle.uri, "completeness": 1.0}, + ) + await append_event( + session, + run, + EventKind.LOCK_RELEASED.value, + "DUT 与测试主机独占锁已释放", + ) + await session.execute(delete(ResourceLock).where(ResourceLock.run_id == run.id)) + await append_event( + session, + run, + EventKind.RUN_COMPLETED.value, + "全部循环完成,结论 PASS", + target_state=RunState.COMPLETED.value, + projection={"verdict": Verdict.PASS.value, "ended_at": utcnow()}, + ) + except asyncio.CancelledError: + raise + finally: + _tasks.pop(run_id, None) + + +def start_simulation(run_id: str) -> None: + existing = _tasks.get(run_id) + if existing and not existing.done(): + return + task = asyncio.create_task(simulate_run(run_id)) + _tasks[run_id] = task + _task_refs.add(task) + task.add_done_callback(_task_refs.discard) + + +def cancel_simulation(run_id: str) -> None: + task = _tasks.pop(run_id, None) + if task and not task.done(): + task.cancel() diff --git a/flashops/services/control-plane/flashops_control/services/step_leases.py b/flashops/services/control-plane/flashops_control/services/step_leases.py new file mode 100644 index 0000000..d7421af --- /dev/null +++ b/flashops/services/control-plane/flashops_control/services/step_leases.py @@ -0,0 +1,547 @@ +"""Reliable pull-mode Step lease lifecycle for Host Agents.""" +from __future__ import annotations + +import datetime as _dt +from typing import Any, Dict, List, Optional + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config import get_settings +from ..enums import ( + EventKind, + EventSource, + OnFail, + RunState, + Severity, + StepState, + Verdict, +) +from ..events import append_event +from ..evidence import build_evidence_bundle +from ..models import ResourceLock, Run, RunEvent, RunStep, TestHost, iso_z, utcnow +from ..schemas import AgentStepCompleteBody, AgentStepEvent + +_ACTIVE_STEP_STATES = { + StepState.PENDING.value, + StepState.DISPATCHED.value, + StepState.RUNNING.value, +} + + +class StepLeaseConflict(ValueError): + """The Agent request does not match the current lease generation/state.""" + + +def step_status(step: RunStep, *, replayed: bool = False) -> Dict[str, Any]: + return { + "step_id": step.id, + "run_id": step.run_id, + "state": step.state, + "attempt": step.attempt, + "lease_expires_at": iso_z(step.lease_expires_at), + "replayed": replayed, + } + + +def step_lease(step: RunStep, run: Run) -> Dict[str, Any]: + return { + "step_id": step.id, + "run_id": step.run_id, + "seq": step.seq, + "loop_index": step.loop_index, + "step_key": step.step_key, + "step_type": step.step_type, + "adapter": step.adapter, + "template": step.template, + "params": step.params, + "danger": step.danger, + "idempotent": step.idempotent, + "timeout_s": step.timeout_s, + "attempt": step.attempt, + "lease_expires_at": iso_z(step.lease_expires_at), + # This slice exercises the real process/network protocol but never executes + # a real hardware command. A future signed adapter registry will remove it. + "dry_run": run.params.get("execution_mode") == "agent_dry_run", + } + + +async def _locked_run(session: AsyncSession, run_id: str) -> Run: + run = await session.scalar( + select(Run).where(Run.id == run_id).with_for_update() + ) + if run is None: + raise StepLeaseConflict("Run 不存在") + return run + + +async def _owned_step( + session: AsyncSession, host: TestHost, step_id: str +) -> tuple[RunStep, Run]: + step = await session.scalar( + select(RunStep) + .join(Run, Run.id == RunStep.run_id) + .where(RunStep.id == step_id, Run.test_host_id == host.id) + .with_for_update() + ) + if step is None: + raise StepLeaseConflict("步骤不存在或不属于当前 Agent") + run = await _locked_run(session, step.run_id) + return step, run + + +async def _replayed( + session: AsyncSession, + *, + run_id: str, + step_id: str, + idempotency_key: str, + operation: str, +) -> bool: + event = await session.scalar( + select(RunEvent).where( + RunEvent.run_id == run_id, + RunEvent.idempotency_key == idempotency_key, + ) + ) + if event is None: + return False + if event.step_id != step_id or event.payload.get("_operation") != operation: + raise StepLeaseConflict("Idempotency-Key 已用于其他步骤或操作") + return True + + +def _assert_current(step: RunStep, agent_id: str, attempt: int) -> None: + now = utcnow() + if step.leased_by != agent_id or step.attempt != attempt: + raise StepLeaseConflict("租约代次不匹配,步骤可能已被重新派发") + if step.lease_expires_at is None or step.lease_expires_at <= now: + raise StepLeaseConflict("步骤租约已过期") + if ( + step.state == StepState.RUNNING.value + and step.started_at is not None + and step.started_at + _dt.timedelta(seconds=step.timeout_s) <= now + ): + raise StepLeaseConflict("步骤执行已超过 timeout_s") + + +async def _freeze_run( + session: AsyncSession, run: Run, step: RunStep, reason: str +) -> None: + if run.state not in {RunState.RUNNING.value, RunState.RECOVERING.value}: + return + await append_event( + session, + run, + EventKind.RUN_FROZEN.value, + reason, + severity=Severity.CRITICAL.value, + target_state=RunState.FROZEN.value, + projection={ + "verdict": Verdict.INCONCLUSIVE.value, + "freeze_reason": reason, + "ended_at": utcnow(), + }, + step_id=step.id, + loop_index=step.loop_index, + ) + await session.execute(delete(ResourceLock).where(ResourceLock.run_id == run.id)) + + +async def reclaim_expired_leases(session: AsyncSession, host: TestHost) -> int: + """Requeue safe work and freeze ambiguous non-idempotent work.""" + + now = utcnow() + expired = ( + await session.scalars( + select(RunStep) + .join(Run, Run.id == RunStep.run_id) + .where( + Run.test_host_id == host.id, + Run.state.in_([RunState.RUNNING.value, RunState.RECOVERING.value]), + RunStep.state.in_([StepState.DISPATCHED.value, StepState.RUNNING.value]), + RunStep.lease_expires_at.is_not(None), + RunStep.lease_expires_at <= now, + ) + .order_by(RunStep.run_id, RunStep.seq) + .with_for_update() + ) + ).all() + + for step in expired: + run = await _locked_run(session, step.run_id) + previous = step.state + step.leased_by = None + step.lease_expires_at = None + + if previous == StepState.DISPATCHED.value: + step.state = StepState.PENDING.value + await append_event( + session, + run, + EventKind.STEP_LEASE_EXPIRED.value, + "Agent 未 ACK,步骤租约已回收", + severity=Severity.WARNING.value, + payload={"attempt": step.attempt, "previous_state": previous}, + step_id=step.id, + loop_index=step.loop_index, + ) + continue + + can_retry = step.idempotent and step.attempt <= step.max_retry + if can_retry: + step.state = StepState.PENDING.value + await append_event( + session, + run, + EventKind.STEP_RETRYING.value, + "Agent 执行中失联,幂等步骤已安全回收等待重试", + severity=Severity.WARNING.value, + payload={"attempt": step.attempt, "max_retry": step.max_retry}, + step_id=step.id, + loop_index=step.loop_index, + ) + continue + + step.state = StepState.TIMED_OUT.value + step.ended_at = now + await append_event( + session, + run, + EventKind.STEP_TIMED_OUT.value, + "执行租约到期,步骤结果不确定", + severity=Severity.ERROR.value, + payload={ + "attempt": step.attempt, + "idempotent": step.idempotent, + "retry_exhausted": step.idempotent, + }, + step_id=step.id, + loop_index=step.loop_index, + ) + await _freeze_run( + session, + run, + step, + "步骤 {0} 执行结果不确定,已冻结现场禁止盲目重跑".format(step.step_key), + ) + + return len(expired) + + +async def try_lease_next_step( + session: AsyncSession, host: TestHost +) -> Optional[Dict[str, Any]]: + await reclaim_expired_leases(session, host) + runs = ( + await session.scalars( + select(Run) + .where( + Run.test_host_id == host.id, + Run.state == RunState.RUNNING.value, + ) + .order_by(Run.created_at, Run.id) + .with_for_update(skip_locked=True) + ) + ).all() + + for run in runs: + steps = ( + await session.scalars( + select(RunStep) + .where(RunStep.run_id == run.id, RunStep.state.in_(_ACTIVE_STEP_STATES)) + .order_by(RunStep.seq) + .with_for_update(skip_locked=True) + ) + ).all() + if not steps: + continue + step = steps[0] + if step.state != StepState.PENDING.value: + continue + + step.state = StepState.DISPATCHED.value + step.attempt += 1 + step.leased_by = host.agent_id + step.lease_expires_at = utcnow() + _dt.timedelta( + seconds=get_settings().timing.lease_ttl_s + ) + await append_event( + session, + run, + EventKind.STEP_DISPATCHED.value, + "步骤已租给 Host Agent,等待 ACK", + payload={"agent_id": host.agent_id, "attempt": step.attempt}, + step_id=step.id, + loop_index=step.loop_index, + ) + return step_lease(step, run) + return None + + +async def acknowledge_step( + session: AsyncSession, + host: TestHost, + step_id: str, + attempt: int, + idempotency_key: str, +) -> Dict[str, Any]: + step, run = await _owned_step(session, host, step_id) + if await _replayed( + session, + run_id=run.id, + step_id=step.id, + idempotency_key=idempotency_key, + operation="ack", + ): + return step_status(step, replayed=True) + if step.state != StepState.DISPATCHED.value: + raise StepLeaseConflict("只有 DISPATCHED 步骤可以 ACK") + _assert_current(step, host.agent_id or "", attempt) + + now = utcnow() + step.state = StepState.RUNNING.value + step.started_at = now + step.lease_expires_at = min( + now + _dt.timedelta(seconds=get_settings().timing.lease_ttl_s), + now + _dt.timedelta(seconds=step.timeout_s), + ) + await append_event( + session, + run, + EventKind.STEP_STARTED.value, + "Host Agent 已确认接手步骤", + source=EventSource.AGENT.value, + payload={"_operation": "ack", "attempt": attempt}, + idempotency_key=idempotency_key, + step_id=step.id, + loop_index=step.loop_index, + ) + return step_status(step) + +async def renew_step( + session: AsyncSession, + host: TestHost, + step_id: str, + attempt: int, + idempotency_key: str, +) -> Dict[str, Any]: + step, run = await _owned_step(session, host, step_id) + if await _replayed( + session, + run_id=run.id, + step_id=step.id, + idempotency_key=idempotency_key, + operation="renew", + ): + return step_status(step, replayed=True) + if step.state != StepState.RUNNING.value: + raise StepLeaseConflict("只有 RUNNING 步骤可以续租") + _assert_current(step, host.agent_id or "", attempt) + assert step.started_at is not None + now = utcnow() + step.lease_expires_at = min( + now + _dt.timedelta(seconds=get_settings().timing.lease_ttl_s), + step.started_at + _dt.timedelta(seconds=step.timeout_s), + ) + await append_event( + session, + run, + EventKind.STEP_LEASE_RENEWED.value, + "Host Agent 已续租执行步骤", + source=EventSource.AGENT.value, + severity=Severity.DEBUG.value, + payload={"_operation": "renew", "attempt": attempt}, + idempotency_key=idempotency_key, + step_id=step.id, + loop_index=step.loop_index, + ) + return step_status(step) + + +async def record_step_events( + session: AsyncSession, + host: TestHost, + step_id: str, + attempt: int, + events: List[AgentStepEvent], + idempotency_key: str, +) -> Dict[str, Any]: + step, run = await _owned_step(session, host, step_id) + first_key = "{0}:0".format(idempotency_key) + if await _replayed( + session, + run_id=run.id, + step_id=step.id, + idempotency_key=first_key, + operation="events", + ): + return {**step_status(step, replayed=True), "accepted": len(events)} + if step.state != StepState.RUNNING.value: + raise StepLeaseConflict("只有 RUNNING 步骤可以上报事件") + _assert_current(step, host.agent_id or "", attempt) + + for index, item in enumerate(events): + await append_event( + session, + run, + EventKind.STEP_OUTPUT.value, + item.message, + source=EventSource.AGENT.value, + severity=item.severity, + payload={"_operation": "events", "attempt": attempt, **item.payload}, + idempotency_key="{0}:{1}".format(idempotency_key, index), + step_id=step.id, + loop_index=step.loop_index, + ) + return {**step_status(step), "accepted": len(events)} + + +async def _finish_run(session: AsyncSession, run: Run, verdict: str) -> None: + await append_event( + session, + run, + EventKind.LOCK_RELEASED.value, + "DUT 与测试主机独占锁已释放", + ) + await session.execute(delete(ResourceLock).where(ResourceLock.run_id == run.id)) + await append_event( + session, + run, + EventKind.RUN_COMPLETED.value, + "独立 Host Agent 已完成全部步骤,结论 {0}".format(verdict), + target_state=RunState.COMPLETED.value, + projection={"verdict": verdict, "ended_at": utcnow()}, + ) + bundle = await build_evidence_bundle(session, run) + await append_event( + session, + run, + EventKind.EVIDENCE_BUNDLED.value, + "Evidence Bundle 已生成,完整率 100%", + payload={"bundle_id": bundle.id, "uri": bundle.uri, "completeness": 1.0}, + ) + + +async def complete_step( + session: AsyncSession, + host: TestHost, + step_id: str, + body: AgentStepCompleteBody, + idempotency_key: str, +) -> Dict[str, Any]: + step, run = await _owned_step(session, host, step_id) + if await _replayed( + session, + run_id=run.id, + step_id=step.id, + idempotency_key=idempotency_key, + operation="complete", + ): + return step_status(step, replayed=True) + if step.state != StepState.RUNNING.value: + raise StepLeaseConflict("只有 RUNNING 步骤可以完成") + _assert_current(step, host.agent_id or "", body.attempt) + + step.exit_code = body.exit_code + step.error_class = body.error_class + step.result = body.result + step.log_uri = body.log_uri + step.ended_at = utcnow() + step.leased_by = None + step.lease_expires_at = None + + succeeded = body.status == StepState.SUCCEEDED.value + step.state = StepState.SUCCEEDED.value if succeeded else StepState.FAILED.value + await append_event( + session, + run, + EventKind.STEP_SUCCEEDED.value if succeeded else EventKind.STEP_FAILED.value, + "步骤执行成功" if succeeded else "步骤执行失败", + source=EventSource.AGENT.value, + severity=Severity.INFO.value if succeeded else Severity.ERROR.value, + payload={ + "_operation": "complete", + "attempt": body.attempt, + "exit_code": body.exit_code, + "error_class": body.error_class, + "result": body.result, + "log_uri": body.log_uri, + }, + idempotency_key=idempotency_key, + step_id=step.id, + loop_index=step.loop_index, + ) + + if not succeeded: + if ( + step.on_fail == OnFail.RETRY.value + and step.idempotent + and step.attempt <= step.max_retry + ): + step.state = StepState.PENDING.value + await append_event( + session, + run, + EventKind.STEP_RETRYING.value, + "幂等步骤失败,已进入受限重试队列", + severity=Severity.WARNING.value, + payload={"attempt": step.attempt, "max_retry": step.max_retry}, + step_id=step.id, + loop_index=step.loop_index, + ) + return step_status(step) + if step.on_fail in {OnFail.FREEZE.value, OnFail.RECOVER.value} or not step.idempotent: + await _freeze_run( + session, + run, + step, + "步骤 {0} 失败且不可安全自动重跑,已冻结现场".format(step.step_key), + ) + return step_status(step) + if step.on_fail == OnFail.FAIL_RUN.value: + await _finish_run(session, run, Verdict.FAIL.value) + return step_status(step) + + remaining = await session.scalar( + select(func.count()) + .select_from(RunStep) + .where(RunStep.run_id == run.id, RunStep.state.in_(_ACTIVE_STEP_STATES)) + ) + + if succeeded and step.checkpoint_after: + next_step = await session.scalar( + select(RunStep) + .where(RunStep.run_id == run.id, RunStep.seq > step.seq) + .order_by(RunStep.seq) + .limit(1) + ) + checkpoint = { + "loop_index": step.loop_index, + "completed_step_key": step.step_key, + "next_step_key": next_step.step_key if next_step else None, + } + loop_done = max(run.loop_done, step.loop_index or run.loop_done) + await append_event( + session, + run, + EventKind.CHECKPOINT_SAVED.value, + "安全检查点已保存", + payload=checkpoint, + projection={"checkpoint": checkpoint, "loop_done": loop_done}, + step_id=step.id, + loop_index=step.loop_index, + ) + + if not remaining: + failed = await session.scalar( + select(func.count()) + .select_from(RunStep) + .where(RunStep.run_id == run.id, RunStep.state == StepState.FAILED.value) + ) + await _finish_run( + session, + run, + Verdict.FAIL.value if failed else Verdict.PASS.value, + ) + return step_status(step) diff --git a/flashops/services/control-plane/requirements.txt b/flashops/services/control-plane/requirements.txt new file mode 100644 index 0000000..eda4876 --- /dev/null +++ b/flashops/services/control-plane/requirements.txt @@ -0,0 +1,19 @@ +# 控制平面依赖。刻意保持短——每加一个依赖,客户内网私有化部署就多一分摩擦。 +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.30 +sqlalchemy>=2.0.30 +greenlet>=3.0 # SQLAlchemy 异步会话的协程桥接 +aiosqlite>=0.20 # 开发态数据库 +pydantic>=2.9,<3.0 +pyyaml>=6.0 # 工作流定义 +httpx>=0.27 # 测试客户端 + Agent 侧复用 +python-dotenv>=1.0 + +# 生产(docker-compose)才需要,本地开发可不装: +# asyncpg>=0.29 +# redis>=5.0 +# boto3>=1.34 + +# 开发/测试 +pytest>=8.0 +pytest-asyncio>=0.24 diff --git a/flashops/services/control-plane/tests/test_agent_enrollment.py b/flashops/services/control-plane/tests/test_agent_enrollment.py new file mode 100644 index 0000000..36520b8 --- /dev/null +++ b/flashops/services/control-plane/tests/test_agent_enrollment.py @@ -0,0 +1,24 @@ +from fastapi import HTTPException +import pytest + +from flashops_control.api.agents import _check_enrollment_token +from flashops_control.config import reset_settings + + +def test_production_agent_enrollment_is_fail_closed(monkeypatch): + monkeypatch.setenv("FLASHOPS_ENV", "production") + monkeypatch.delenv("FLASHOPS_AGENT_ENROLLMENT_TOKEN", raising=False) + reset_settings() + with pytest.raises(HTTPException) as missing: + _check_enrollment_token(None) + assert missing.value.status_code == 503 + + monkeypatch.setenv("FLASHOPS_AGENT_ENROLLMENT_TOKEN", "enroll-secret") + reset_settings() + with pytest.raises(HTTPException) as invalid: + _check_enrollment_token("wrong") + assert invalid.value.status_code == 401 + _check_enrollment_token("enroll-secret") + + # Leave the global cache empty so later tests read the restored environment. + reset_settings() diff --git a/flashops/services/control-plane/tests/test_agent_status.py b/flashops/services/control-plane/tests/test_agent_status.py new file mode 100644 index 0000000..a47e3dd --- /dev/null +++ b/flashops/services/control-plane/tests/test_agent_status.py @@ -0,0 +1,53 @@ +import datetime as dt + +from flashops_control.config import TimingPolicy +from flashops_control.enums import HostStatus +from flashops_control.services.agent_status import projected_agent_status + + +NOW = dt.datetime(2026, 7, 27, 12, 0, 0) +TIMING = TimingPolicy( + heartbeat_interval_s=5, + heartbeat_degraded_after_s=15, + heartbeat_lost_after_s=30, +) + + +def test_fresh_agent_heartbeat_is_online(): + status = projected_agent_status( + NOW - dt.timedelta(seconds=14), + HostStatus.ONLINE.value, + now=NOW, + timing=TIMING, + ) + assert status == HostStatus.ONLINE.value + + +def test_late_agent_heartbeat_is_degraded(): + status = projected_agent_status( + NOW - dt.timedelta(seconds=15), + HostStatus.ONLINE.value, + now=NOW, + timing=TIMING, + ) + assert status == HostStatus.DEGRADED.value + + +def test_lost_agent_heartbeat_is_offline(): + status = projected_agent_status( + NOW - dt.timedelta(seconds=30), + HostStatus.DEGRADED.value, + now=NOW, + timing=TIMING, + ) + assert status == HostStatus.OFFLINE.value + + +def test_fresh_unhealthy_agent_stays_degraded_until_healthy_report(): + status = projected_agent_status( + NOW, + HostStatus.DEGRADED.value, + now=NOW, + timing=TIMING, + ) + assert status == HostStatus.DEGRADED.value diff --git a/flashops/services/control-plane/tests/test_e2e_vertical_slice.py b/flashops/services/control-plane/tests/test_e2e_vertical_slice.py new file mode 100644 index 0000000..43cbf2f --- /dev/null +++ b/flashops/services/control-plane/tests/test_e2e_vertical_slice.py @@ -0,0 +1,413 @@ +import os +import time +import uuid +from pathlib import Path + +TEST_DB = Path("/tmp/flashops-e2e-{0}.db".format(os.getpid())) +TEST_OBJECTS = Path("/tmp/flashops-e2e-objects-{0}".format(os.getpid())) +os.environ["FLASHOPS_DATABASE_URL"] = "sqlite+aiosqlite:///" + str(TEST_DB) +os.environ["FLASHOPS_OBJECT_STORE_URL"] = str(TEST_OBJECTS) +os.environ["FLASHOPS_TIME_SCALE"] = "0.01" + +from fastapi.testclient import TestClient + +from flashops_control.config import reset_settings +from flashops_control.config import get_settings +from flashops_control.main import app + + +def setup_module(): + reset_settings() + TEST_DB.unlink(missing_ok=True) + + +def teardown_module(): + TEST_DB.unlink(missing_ok=True) + + +def test_demo_run_recovers_and_builds_evidence(): + with TestClient(app) as client: + health = client.get("/api/v1/health") + assert health.status_code == 200 + + preflight = client.post("/api/v1/preflight", json={}) + assert preflight.status_code == 200 + assert preflight.json()["passed"] is True + assert len(preflight.json()["checks"]) == 6 + + created = client.post( + "/api/v1/runs", + json={"loops": 4, "inject_failure": True, "created_by": "pytest"}, + ) + assert created.status_code == 201 + run_id = created.json()["id"] + + run = None + for _ in range(100): + response = client.get("/api/v1/runs/" + run_id) + assert response.status_code == 200 + run = response.json() + if run["state"] == "COMPLETED": + break + time.sleep(0.02) + + assert run is not None + assert run["state"] == "COMPLETED" + assert run["verdict"] == "PASS" + assert run["loop_done"] == 4 + assert run["recovery_count"] == 3 + assert run["evidence"]["completeness"] == 1.0 + assert Path(run["evidence"]["uri"], "manifest.json").exists() + + events = client.get("/api/v1/runs/{0}/events".format(run_id)).json() + kinds = [event["kind"] for event in events] + assert [event["seq"] for event in events] == list(range(1, len(events) + 1)) + assert "HEARTBEAT_LOST" in kinds + assert kinds.count("RECOVERY_STARTED") == 3 + assert "RECOVERY_SUCCEEDED" in kinds + assert kinds[-1] == "RUN_COMPLETED" + + +def test_mismatched_serial_is_rejected_before_run_creation(): + with TestClient(app) as client: + response = client.post( + "/api/v1/preflight", json={"discovered_serial": "SYSTEM-DISK"} + ) + assert response.status_code == 200 + payload = response.json() + assert payload["passed"] is False + serial = next(check for check in payload["checks"] if check["key"] == "serial") + assert serial["passed"] is False + + +def test_pause_resume_and_emergency_stop_are_audited(): + with TestClient(app) as client: + created = client.post( + "/api/v1/runs", + json={"loops": 50, "inject_failure": False, "created_by": "pytest-actions"}, + ) + assert created.status_code == 201 + run_id = created.json()["id"] + + run = created.json() + for _ in range(50): + run = client.get("/api/v1/runs/" + run_id).json() + if run["state"] == "RUNNING": + break + time.sleep(0.01) + assert run["state"] == "RUNNING" + + paused = client.post( + "/api/v1/runs/{0}/pause".format(run_id), + json={"actor": "pytest", "reason": "检查暂停"}, + ) + assert paused.status_code == 200 + assert paused.json()["state"] == "PAUSED" + + resumed = client.post( + "/api/v1/runs/{0}/resume".format(run_id), + json={"actor": "pytest", "reason": "检查继续"}, + ) + assert resumed.status_code == 200 + assert resumed.json()["state"] == "RUNNING" + + stopped = client.post( + "/api/v1/runs/{0}/emergency-stop".format(run_id), + json={"actor": "pytest", "reason": "检查急停"}, + ) + assert stopped.status_code == 200 + assert stopped.json()["state"] == "ABORTED" + assert stopped.json()["human_touches"] == 3 + events = client.get("/api/v1/runs/{0}/events".format(run_id)).json() + kinds = [event["kind"] for event in events] + assert "RUN_PAUSED" in kinds + assert "RUN_RESUMED" in kinds + assert kinds[-1] == "RUN_ABORTED" + + +def test_agent_registration_heartbeat_and_token_rotation(): + with TestClient(app) as client: + registered = client.post( + "/api/v1/agent/register", + json={ + "host_name": "WS-05", + "agent_version": "0.1.0-test", + "os_family": "linux", + "os_version": "pytest", + "kernel": "test-kernel", + "ip_address": "192.0.2.50", + "toolchain": {"fio": "fio-3.36"}, + "labels": {"system_device_paths": ["/dev/nvme0n1"]}, + }, + ) + assert registered.status_code == 201 + credentials = registered.json() + assert credentials["agent_id"].startswith("agent_") + assert credentials["host_id"] + assert len(credentials["token"]) >= 32 + + missing_token = client.post( + "/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]), + json={"healthy": True}, + ) + assert missing_token.status_code == 401 + + heartbeat = client.post( + "/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]), + headers={"Authorization": "Bearer " + credentials["token"]}, + json={ + "healthy": True, + "agent_version": "0.1.1-test", + "device_probe": {"nvme": ["nvme1n1"]}, + }, + ) + assert heartbeat.status_code == 200 + assert heartbeat.json()["ok"] is True + assert heartbeat.json()["commands"] == [] + + rotated = client.post( + "/api/v1/agent/register", + json={ + "host_id": credentials["host_id"], + "agent_version": "0.1.1-test", + }, + ) + assert rotated.status_code == 201 + rotated_credentials = rotated.json() + assert rotated_credentials["agent_id"] == credentials["agent_id"] + assert rotated_credentials["token"] != credentials["token"] + + old_token = client.post( + "/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]), + headers={"Authorization": "Bearer " + credentials["token"]}, + json={"healthy": True}, + ) + assert old_token.status_code == 401 + + new_token = client.post( + "/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]), + headers={"Authorization": "Bearer " + rotated_credentials["token"]}, + json={"healthy": False}, + ) + assert new_token.status_code == 200 + + hosts = client.get("/api/v1/agent/hosts") + assert hosts.status_code == 200 + current = next( + item for item in hosts.json() if item["host_id"] == credentials["host_id"] + ) + assert current["agent_id"] == credentials["agent_id"] + assert current["status"] == "DEGRADED" + assert "token" not in current + + +def _register_seed_agent(client): + response = client.post( + "/api/v1/agent/register", + json={"host_name": "WS-05", "agent_version": "0.2.0-pytest"}, + ) + assert response.status_code == 201 + return response.json() + + +def _idem_headers(credentials, key=None): + return { + "Authorization": "Bearer " + credentials["token"], + "Idempotency-Key": key or str(uuid.uuid4()), + } + + +def _lease(client, credentials): + return client.post( + "/api/v1/agent/{0}/lease".format(credentials["agent_id"]), + headers={"Authorization": "Bearer " + credentials["token"]}, + json={"wait_timeout_s": 0}, + ) + + +def _ack(client, credentials, lease, key=None): + return client.post( + "/api/v1/agent/{0}/steps/{1}/ack".format( + credentials["agent_id"], lease["step_id"] + ), + headers=_idem_headers(credentials, key), + json={"attempt": lease["attempt"]}, + ) + + +def _complete(client, credentials, lease, key=None): + return client.post( + "/api/v1/agent/{0}/steps/{1}/complete".format( + credentials["agent_id"], lease["step_id"] + ), + headers=_idem_headers(credentials, key), + json={ + "attempt": lease["attempt"], + "status": "SUCCEEDED", + "exit_code": 0, + "result": {"simulated": True}, + }, + ) + + +def test_agent_step_lease_idempotency_and_full_dry_run(): + with TestClient(app) as client: + credentials = _register_seed_agent(client) + created = client.post( + "/api/v1/runs", + json={ + "loops": 2, + "inject_failure": False, + "execution_mode": "agent_dry_run", + "created_by": "pytest-agent-lease", + }, + ) + assert created.status_code == 201 + run_id = created.json()["id"] + assert created.json()["state"] == "RUNNING" + assert created.json()["execution_mode"] == "agent_dry_run" + + unauthorized = client.post( + "/api/v1/agent/{0}/lease".format(credentials["agent_id"]), + json={"wait_timeout_s": 0}, + ) + assert unauthorized.status_code == 401 + + first = _lease(client, credentials) + assert first.status_code == 200 + lease = first.json() + assert lease["step_key"] == "flash-fw-b" + assert lease["attempt"] == 1 + assert lease["dry_run"] is True + + ack_key = str(uuid.uuid4()) + acked = _ack(client, credentials, lease, ack_key) + assert acked.status_code == 200 + assert acked.json()["state"] == "RUNNING" + replayed_ack = _ack(client, credentials, lease, ack_key) + assert replayed_ack.status_code == 200 + assert replayed_ack.json()["replayed"] is True + + renew = client.post( + "/api/v1/agent/{0}/steps/{1}/renew".format( + credentials["agent_id"], lease["step_id"] + ), + headers=_idem_headers(credentials), + json={"attempt": lease["attempt"]}, + ) + assert renew.status_code == 200 + + event_key = str(uuid.uuid4()) + output = client.post( + "/api/v1/agent/{0}/steps/{1}/events".format( + credentials["agent_id"], lease["step_id"] + ), + headers=_idem_headers(credentials, event_key), + json={ + "attempt": lease["attempt"], + "events": [ + {"message": "dry-run output 1", "payload": {"line": 1}}, + {"message": "dry-run output 2", "severity": "DEBUG"}, + ], + }, + ) + assert output.status_code == 200 + assert output.json()["accepted"] == 2 + replayed_output = client.post( + "/api/v1/agent/{0}/steps/{1}/events".format( + credentials["agent_id"], lease["step_id"] + ), + headers=_idem_headers(credentials, event_key), + json={ + "attempt": lease["attempt"], + "events": [ + {"message": "dry-run output 1", "payload": {"line": 1}}, + {"message": "dry-run output 2", "severity": "DEBUG"}, + ], + }, + ) + assert replayed_output.status_code == 200 + assert replayed_output.json()["replayed"] is True + + complete_key = str(uuid.uuid4()) + completed = _complete(client, credentials, lease, complete_key) + assert completed.status_code == 200 + replayed_complete = _complete(client, credentials, lease, complete_key) + assert replayed_complete.status_code == 200 + assert replayed_complete.json()["replayed"] is True + + step_count = 1 + while True: + leased = _lease(client, credentials) + if leased.status_code == 204: + break + assert leased.status_code == 200 + item = leased.json() + assert _ack(client, credentials, item).status_code == 200 + assert _complete(client, credentials, item).status_code == 200 + step_count += 1 + + assert step_count == 7 # flash + (3 loop-body steps * 2 loops) + run = client.get("/api/v1/runs/" + run_id).json() + assert run["state"] == "COMPLETED" + assert run["verdict"] == "PASS" + assert run["loop_done"] == 2 + assert run["evidence"]["completeness"] == 1.0 + events = client.get("/api/v1/runs/{0}/events".format(run_id)).json() + assert [event["seq"] for event in events] == list(range(1, len(events) + 1)) + assert sum(event["kind"] == "STEP_DISPATCHED" for event in events) == 7 + assert sum(event["kind"] == "STEP_SUCCEEDED" for event in events) == 7 + + +def test_expired_running_lease_retries_only_idempotent_steps(): + timing = get_settings().timing + original_ttl = timing.lease_ttl_s + object.__setattr__(timing, "lease_ttl_s", 0.01) + try: + with TestClient(app) as client: + credentials = _register_seed_agent(client) + + # The first workflow step is a non-idempotent firmware action. Once + # ACKed, an expired lease must freeze the run instead of rerunning it. + created = client.post( + "/api/v1/runs", + json={"loops": 1, "execution_mode": "agent_dry_run"}, + ) + assert created.status_code == 201 + run_id = created.json()["id"] + lease = _lease(client, credentials).json() + assert lease["idempotent"] is False + assert _ack(client, credentials, lease).status_code == 200 + time.sleep(0.02) + assert _lease(client, credentials).status_code == 204 + frozen = client.get("/api/v1/runs/" + run_id).json() + assert frozen["state"] == "FROZEN" + assert frozen["verdict"] == "INCONCLUSIVE" + + # On another run, finish the firmware step and let the idempotent fio + # workload expire. It is safely reissued once with attempt=2. + created_retry = client.post( + "/api/v1/runs", + json={"loops": 1, "execution_mode": "agent_dry_run"}, + ) + assert created_retry.status_code == 201 + retry_run_id = created_retry.json()["id"] + firmware = _lease(client, credentials).json() + assert _ack(client, credentials, firmware).status_code == 200 + assert _complete(client, credentials, firmware).status_code == 200 + workload = _lease(client, credentials).json() + assert workload["step_key"] == "workload" + assert workload["idempotent"] is True + assert _ack(client, credentials, workload).status_code == 200 + time.sleep(0.02) + retried = _lease(client, credentials) + assert retried.status_code == 200 + assert retried.json()["step_id"] == workload["step_id"] + assert retried.json()["attempt"] == 2 + stopped = client.post( + "/api/v1/runs/{0}/emergency-stop".format(retry_run_id), + json={"actor": "pytest", "reason": "清理重试场景"}, + ) + assert stopped.status_code == 200 + finally: + object.__setattr__(timing, "lease_ttl_s", original_ttl) diff --git a/flashops/services/control-plane/tests/test_recovery.py b/flashops/services/control-plane/tests/test_recovery.py new file mode 100644 index 0000000..5626ba3 --- /dev/null +++ b/flashops/services/control-plane/tests/test_recovery.py @@ -0,0 +1,40 @@ +from flashops_control.engine.recovery import RecoveryContext, next_recovery_level +from flashops_control.enums import RecoveryLevel, RecoveryTrigger + + +def test_integrity_failure_always_freezes(): + level = next_recovery_level( + RecoveryContext( + trigger=RecoveryTrigger.DATA_INTEGRITY, + attempt=0, + agent_online=True, + host_network_online=True, + oob_online=True, + ) + ) + assert level == RecoveryLevel.FREEZE + + +def test_offline_oob_removes_destructive_recovery_levels(): + level = next_recovery_level( + RecoveryContext( + trigger=RecoveryTrigger.HEARTBEAT_LOST, + attempt=2, + agent_online=True, + host_network_online=True, + oob_online=False, + ) + ) + assert level == RecoveryLevel.FREEZE + + +def test_recovery_escalates_in_order(): + context = dict( + trigger=RecoveryTrigger.HEARTBEAT_LOST, + agent_online=True, + host_network_online=True, + oob_online=True, + ) + assert next_recovery_level(RecoveryContext(attempt=0, **context)) == RecoveryLevel.AGENT_SOFT + assert next_recovery_level(RecoveryContext(attempt=1, **context)) == RecoveryLevel.OS_REBOOT + assert next_recovery_level(RecoveryContext(attempt=2, **context)) == RecoveryLevel.OOB_RESET diff --git a/flashops/services/control-plane/tests/test_safety.py b/flashops/services/control-plane/tests/test_safety.py new file mode 100644 index 0000000..6caf547 --- /dev/null +++ b/flashops/services/control-plane/tests/test_safety.py @@ -0,0 +1,39 @@ +from flashops_control.safety import PreflightRequest, run_preflight + + +def valid_request(**overrides): + values = { + "expected_serial": "DUT-001", + "discovered_serial": "DUT-001", + "allow_destructive": True, + "device_path": "/dev/nvme1n1", + "system_device_paths": ["/dev/nvme0n1", "/dev/nvme0n1p2"], + "firmware_model_allowed": True, + "host_online": True, + "oob_online": True, + } + values.update(overrides) + return PreflightRequest(**values) + + +def test_all_safety_gates_pass_for_bound_test_disk(): + result = run_preflight(valid_request()) + assert result.passed is True + assert len(result.checks) == 6 + + +def test_serial_mismatch_is_rejected(): + result = run_preflight(valid_request(discovered_serial="WRONG-DISK")) + assert result.passed is False + assert next(check for check in result.checks if check.key == "serial").passed is False + + +def test_system_partition_is_rejected(): + result = run_preflight(valid_request(device_path="/dev/nvme0n1p2")) + assert result.passed is False + assert next(check for check in result.checks if check.key == "system_disk").passed is False + + +def test_destructive_opt_in_is_required(): + result = run_preflight(valid_request(allow_destructive=False)) + assert result.passed is False diff --git a/flashops/services/control-plane/tests/test_state_machine.py b/flashops/services/control-plane/tests/test_state_machine.py new file mode 100644 index 0000000..3683bc1 --- /dev/null +++ b/flashops/services/control-plane/tests/test_state_machine.py @@ -0,0 +1,22 @@ +import pytest + +from flashops_control.engine.states import IllegalTransition, assert_run_transition +from flashops_control.enums import RunState + + +def test_happy_path_transitions_are_allowed(): + assert_run_transition(RunState.QUEUED.value, RunState.PREFLIGHT.value) + assert_run_transition(RunState.PREFLIGHT.value, RunState.RUNNING.value) + assert_run_transition(RunState.RUNNING.value, RunState.RECOVERING.value) + assert_run_transition(RunState.RECOVERING.value, RunState.RUNNING.value) + assert_run_transition(RunState.RUNNING.value, RunState.COMPLETED.value) + + +def test_terminal_state_cannot_transition(): + with pytest.raises(IllegalTransition): + assert_run_transition(RunState.COMPLETED.value, RunState.RUNNING.value) + + +def test_rejected_cannot_be_resumed(): + with pytest.raises(IllegalTransition): + assert_run_transition(RunState.REJECTED.value, RunState.QUEUED.value) diff --git a/flashops/services/host-agent/README.md b/flashops/services/host-agent/README.md new file mode 100644 index 0000000..7d07980 --- /dev/null +++ b/flashops/services/host-agent/README.md @@ -0,0 +1,56 @@ +# FlashOps Host Agent + +The Host Agent is a pull-mode process that runs on a test host. The first +implemented slice supports: + +- read-only host fingerprint discovery; +- enrollment into a Test Host record; +- one-time bearer token issuance; +- local credential persistence with mode `0600`; +- authenticated heartbeats and Host status updates; +- server-side heartbeat aging to `DEGRADED` after 15s and `OFFLINE` after 30s; +- optional gateway Basic Auth for deployments that enable an authenticated proxy. + +Step leases, command templates, event uploads, artifacts, and completion +reporting remain the next slice. + +## Local run + +Start the control plane in one terminal: + +```bash +make dev +``` + +Register and send one heartbeat in another: + +```bash +make dev-agent +``` + +Run continuously: + +```bash +make dev-agent-loop +``` + +By default the Agent state is stored at `~/.flashops/agent-state.json`. It +contains the raw bearer token and is created with mode `0600`. The control +plane stores only its SHA-256 digest. + +## Configuration + +| Variable | Purpose | +|---|---| +| `FLASHOPS_AGENT_SERVER` | Control plane base URL | +| `FLASHOPS_AGENT_HOST_ID` | Bind to an existing Test Host ID | +| `FLASHOPS_AGENT_HOST_NAME` | Bind to or create a Test Host by name | +| `FLASHOPS_AGENT_STATE_PATH` | Local credential state path | +| `FLASHOPS_AGENT_ENROLLMENT_TOKEN` | First-registration secret | +| `FLASHOPS_AGENT_BASIC_USER` | Optional authenticated-proxy user | +| `FLASHOPS_AGENT_BASIC_PASSWORD` | Optional authenticated-proxy password | + +Production registration is denied when the control plane does not have +`FLASHOPS_AGENT_ENROLLMENT_TOKEN` configured. Store that value in +`/etc/flashops/flashops.env` with root-only permissions; never put it in the +systemd unit or repository. diff --git a/flashops/services/host-agent/flashops_agent/__init__.py b/flashops/services/host-agent/flashops_agent/__init__.py new file mode 100644 index 0000000..4acb5c7 --- /dev/null +++ b/flashops/services/host-agent/flashops_agent/__init__.py @@ -0,0 +1,3 @@ +"""FlashOps pull-mode Host Agent.""" + +__version__ = "0.1.0" diff --git a/flashops/services/host-agent/flashops_agent/__main__.py b/flashops/services/host-agent/flashops_agent/__main__.py new file mode 100644 index 0000000..480bc00 --- /dev/null +++ b/flashops/services/host-agent/flashops_agent/__main__.py @@ -0,0 +1,180 @@ +"""Command-line entry point for the pull-mode Host Agent.""" +from __future__ import annotations + +import argparse +import os +import sys +import time +import uuid +from pathlib import Path +from typing import Optional, Tuple + +from .client import AgentUnauthorized, ControlPlaneClient, ControlPlaneError +from .fingerprint import heartbeat_payload, registration_payload +from .state import AgentState, StateStore + + +def _execute_dry_run(lease: dict) -> dict: + """Exercise the distributed worker path without invoking any host command.""" + + if lease.get("dry_run") is not True: + raise RuntimeError("Agent 拒绝执行未注册的真实适配器") + return { + "simulated": True, + "adapter": lease.get("adapter"), + "template": lease.get("template"), + "step_key": lease.get("step_key"), + } + + +def _process_one_lease( + client: ControlPlaneClient, state: AgentState +) -> Optional[str]: + lease = client.lease(state.agent_id, state.token, wait_timeout_s=0) + if lease is None: + return None + step_id = str(lease["step_id"]) + attempt = int(lease["attempt"]) + client.ack( + state.agent_id, + state.token, + step_id, + attempt, + str(uuid.uuid4()), + ) + client.report_events( + state.agent_id, + state.token, + step_id, + attempt, + [{"message": "Agent dry-run 正在验证步骤协议", "severity": "INFO"}], + str(uuid.uuid4()), + ) + result = _execute_dry_run(lease) + client.complete( + state.agent_id, + state.token, + step_id, + attempt, + status="SUCCEEDED", + exit_code=0, + result=result, + idempotency_key=str(uuid.uuid4()), + ) + return str(lease.get("step_key") or step_id) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="FlashOps Host Agent") + parser.add_argument( + "--server", + default=os.environ.get("FLASHOPS_AGENT_SERVER", "http://127.0.0.1:8000"), + ) + parser.add_argument("--host-id", default=os.environ.get("FLASHOPS_AGENT_HOST_ID")) + parser.add_argument("--host-name", default=os.environ.get("FLASHOPS_AGENT_HOST_NAME")) + parser.add_argument( + "--state-path", + type=Path, + default=Path( + os.environ.get( + "FLASHOPS_AGENT_STATE_PATH", + str(Path.home() / ".flashops" / "agent-state.json"), + ) + ), + ) + parser.add_argument( + "--enrollment-token", + default=os.environ.get("FLASHOPS_AGENT_ENROLLMENT_TOKEN"), + ) + parser.add_argument( + "--basic-user", + default=os.environ.get("FLASHOPS_AGENT_BASIC_USER"), + ) + parser.add_argument( + "--basic-password", + default=os.environ.get("FLASHOPS_AGENT_BASIC_PASSWORD"), + ) + parser.add_argument("--interval", type=float, default=None) + parser.add_argument("--once", action="store_true") + return parser + + +def _basic_auth(user: Optional[str], password: Optional[str]) -> Optional[Tuple[str, str]]: + if bool(user) != bool(password): + raise ValueError("Basic Auth 用户名和密码必须同时提供") + return (user, password) if user and password else None + + +def _register( + client: ControlPlaneClient, + store: StateStore, + server: str, + host_id: Optional[str], + host_name: Optional[str], +) -> AgentState: + payload = client.register( + registration_payload(host_id=host_id, host_name=host_name) + ) + state = AgentState.from_payload(server, payload) + store.save(state) + print("Agent 已注册: {0} → Host {1}".format(state.agent_id, state.host_id)) + return state + + +def main() -> int: + args = _parser().parse_args() + server = args.server.rstrip("/") + store = StateStore(args.state_path) + try: + basic_auth = _basic_auth(args.basic_user, args.basic_password) + with ControlPlaneClient( + server, + enrollment_token=args.enrollment_token, + basic_auth=basic_auth, + ) as client: + state = store.load() + if state is None or state.server != server: + state = _register( + client, store, server, args.host_id, args.host_name + ) + + while True: + try: + response = client.heartbeat( + state.agent_id, + state.token, + heartbeat_payload(), + ) + except AgentUnauthorized: + state = _register( + client, store, server, args.host_id, args.host_name + ) + response = client.heartbeat( + state.agent_id, + state.token, + heartbeat_payload(), + ) + + print( + "心跳已确认: {0} · commands={1}".format( + response.get("server_time"), + len(response.get("commands", [])), + ) + ) + completed_step = _process_one_lease(client, state) + if completed_step: + print("dry-run 步骤已完成: {0}".format(completed_step)) + if args.once: + return 0 + interval = args.interval or state.heartbeat_interval_s + time.sleep(max(0.5, interval)) + except KeyboardInterrupt: + print("Agent 已停止") + return 0 + except (ControlPlaneError, OSError, RuntimeError, ValueError) as exc: + print("Agent 错误: {0}".format(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/flashops/services/host-agent/flashops_agent/client.py b/flashops/services/host-agent/flashops_agent/client.py new file mode 100644 index 0000000..c4a3aa9 --- /dev/null +++ b/flashops/services/host-agent/flashops_agent/client.py @@ -0,0 +1,247 @@ +"""Small HTTP client for the control-plane Agent contract.""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +import httpx + + +class ControlPlaneError(RuntimeError): + pass + + +class AgentUnauthorized(ControlPlaneError): + pass + + +class StepLeaseConflict(ControlPlaneError): + pass + + +class ControlPlaneClient: + def __init__( + self, + server: str, + *, + enrollment_token: Optional[str] = None, + basic_auth: Optional[Tuple[str, str]] = None, + timeout_s: float = 15.0, + transport: Optional[httpx.BaseTransport] = None, + ) -> None: + self.server = server.rstrip("/") + self.enrollment_token = enrollment_token + self._client = httpx.Client( + base_url=self.server, + auth=httpx.BasicAuth(*basic_auth) if basic_auth else None, + timeout=timeout_s, + transport=transport, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> "ControlPlaneClient": + return self + + def __exit__(self, *_: object) -> None: + self.close() + + @staticmethod + def _payload(response: httpx.Response) -> Dict[str, Any]: + try: + value = response.json() + except ValueError as exc: + raise ControlPlaneError( + "控制平面返回了非 JSON 响应: HTTP {0}".format(response.status_code) + ) from exc + if not isinstance(value, dict): + raise ControlPlaneError("控制平面响应不是 JSON object") + return value + + def register(self, fingerprint: Dict[str, Any]) -> Dict[str, Any]: + headers = {} + if self.enrollment_token: + headers["X-FlashOps-Enrollment-Token"] = self.enrollment_token + response = self._client.post( + "/api/v1/agent/register", + json=fingerprint, + headers=headers, + ) + if response.status_code == 401: + raise AgentUnauthorized("Agent 注册口令无效") + if response.status_code >= 400: + raise ControlPlaneError( + "Agent 注册失败: HTTP {0} {1}".format( + response.status_code, response.text[:200] + ) + ) + payload = self._payload(response) + for field in ("agent_id", "host_id", "token"): + if not payload.get(field): + raise ControlPlaneError("注册响应缺少字段: {0}".format(field)) + return payload + + def heartbeat( + self, + agent_id: str, + token: str, + heartbeat: Dict[str, Any], + ) -> Dict[str, Any]: + response = self._client.post( + "/api/v1/agent/{0}/heartbeat".format(agent_id), + json=heartbeat, + headers={"Authorization": "Bearer " + token}, + ) + if response.status_code == 401: + raise AgentUnauthorized("Agent token 已失效") + if response.status_code >= 400: + raise ControlPlaneError( + "Agent 心跳失败: HTTP {0} {1}".format( + response.status_code, response.text[:200] + ) + ) + payload = self._payload(response) + if payload.get("ok") is not True: + raise ControlPlaneError("控制平面未确认心跳") + return payload + + @staticmethod + def _agent_headers(token: str, idempotency_key: Optional[str] = None) -> Dict[str, str]: + headers = {"Authorization": "Bearer " + token} + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + return headers + + def lease( + self, + agent_id: str, + token: str, + *, + wait_timeout_s: float = 0.0, + ) -> Optional[Dict[str, Any]]: + response = self._client.post( + "/api/v1/agent/{0}/lease".format(agent_id), + json={"wait_timeout_s": wait_timeout_s}, + headers=self._agent_headers(token), + ) + if response.status_code == 204: + return None + return self._step_response(response, "领取步骤") + + def _step_response(self, response: httpx.Response, action: str) -> Dict[str, Any]: + if response.status_code == 401: + raise AgentUnauthorized("Agent token 已失效") + if response.status_code == 409: + detail = response.text[:300] + try: + detail = str(response.json().get("detail", detail)) + except (ValueError, AttributeError): + pass + raise StepLeaseConflict("{0}冲突: {1}".format(action, detail)) + if response.status_code >= 400: + raise ControlPlaneError( + "{0}失败: HTTP {1} {2}".format( + action, response.status_code, response.text[:200] + ) + ) + return self._payload(response) + + def _step_post( + self, + agent_id: str, + token: str, + step_id: str, + action: str, + payload: Dict[str, Any], + idempotency_key: str, + ) -> Dict[str, Any]: + response = self._client.post( + "/api/v1/agent/{0}/steps/{1}/{2}".format( + agent_id, step_id, action + ), + json=payload, + headers=self._agent_headers(token, idempotency_key), + ) + return self._step_response(response, action) + + def ack( + self, + agent_id: str, + token: str, + step_id: str, + attempt: int, + idempotency_key: str, + ) -> Dict[str, Any]: + return self._step_post( + agent_id, + token, + step_id, + "ack", + {"attempt": attempt}, + idempotency_key, + ) + + def renew( + self, + agent_id: str, + token: str, + step_id: str, + attempt: int, + idempotency_key: str, + ) -> Dict[str, Any]: + return self._step_post( + agent_id, + token, + step_id, + "renew", + {"attempt": attempt}, + idempotency_key, + ) + + def report_events( + self, + agent_id: str, + token: str, + step_id: str, + attempt: int, + events: List[Dict[str, Any]], + idempotency_key: str, + ) -> Dict[str, Any]: + return self._step_post( + agent_id, + token, + step_id, + "events", + {"attempt": attempt, "events": events}, + idempotency_key, + ) + + def complete( + self, + agent_id: str, + token: str, + step_id: str, + attempt: int, + *, + status: str, + idempotency_key: str, + exit_code: Optional[int] = None, + error_class: Optional[str] = None, + result: Optional[Dict[str, Any]] = None, + log_uri: Optional[str] = None, + ) -> Dict[str, Any]: + return self._step_post( + agent_id, + token, + step_id, + "complete", + { + "attempt": attempt, + "status": status, + "exit_code": exit_code, + "error_class": error_class, + "result": result or {}, + "log_uri": log_uri, + }, + idempotency_key, + ) diff --git a/flashops/services/host-agent/flashops_agent/fingerprint.py b/flashops/services/host-agent/flashops_agent/fingerprint.py new file mode 100644 index 0000000..8ec6628 --- /dev/null +++ b/flashops/services/host-agent/flashops_agent/fingerprint.py @@ -0,0 +1,97 @@ +"""Read-only host discovery used during registration and heartbeat.""" +from __future__ import annotations + +import os +import platform +import shutil +import socket +import subprocess +from pathlib import Path +from typing import Any, Dict, List, Optional + +from . import __version__ + + +def _ip_address() -> Optional[str]: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("192.0.2.1", 9)) + return str(sock.getsockname()[0]) + except OSError: + return None + + +def _tool_version(command: str, args: List[str]) -> Optional[str]: + executable = shutil.which(command) + if not executable: + return None + try: + result = subprocess.run( + [executable] + args, + check=False, + capture_output=True, + text=True, + timeout=3, + ) + except (OSError, subprocess.TimeoutExpired): + return None + output = (result.stdout or result.stderr).strip().splitlines() + return output[0][:128] if output else None + + +def discover_toolchain() -> Dict[str, str]: + probes = { + "fio": ("fio", ["--version"]), + "nvme-cli": ("nvme", ["version"]), + "smartctl": ("smartctl", ["--version"]), + } + versions: Dict[str, str] = {} + for name, (command, args) in probes.items(): + version = _tool_version(command, args) + if version: + versions[name] = version + return versions + + +def system_device_paths() -> List[str]: + if platform.system().lower() != "linux": + return [] + try: + for line in Path("/proc/mounts").read_text(encoding="utf-8").splitlines(): + fields = line.split() + if len(fields) >= 2 and fields[1] == "/" and fields[0].startswith("/dev/"): + return [os.path.realpath(fields[0])] + except OSError: + return [] + return [] + + +def registration_payload( + *, + host_id: Optional[str] = None, + host_name: Optional[str] = None, +) -> Dict[str, Any]: + return { + "host_id": host_id, + "host_name": host_name or platform.node() or socket.gethostname(), + "agent_version": __version__, + "os_family": platform.system().lower() or "unknown", + "os_version": platform.platform(), + "kernel": platform.release(), + "cpu": platform.processor() or None, + "motherboard": None, + "bios_version": None, + "ip_address": _ip_address(), + "toolchain": discover_toolchain(), + "labels": {"system_device_paths": system_device_paths()}, + } + + +def heartbeat_payload() -> Dict[str, Any]: + return { + "healthy": True, + "agent_version": __version__, + "ip_address": _ip_address(), + "toolchain": discover_toolchain(), + "device_probe": {"system_device_paths": system_device_paths()}, + } diff --git a/flashops/services/host-agent/flashops_agent/state.py b/flashops/services/host-agent/flashops_agent/state.py new file mode 100644 index 0000000..9f39f74 --- /dev/null +++ b/flashops/services/host-agent/flashops_agent/state.py @@ -0,0 +1,65 @@ +"""Agent credential persistence with restrictive local permissions.""" +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, Optional + + +@dataclass(frozen=True) +class AgentState: + server: str + agent_id: str + host_id: str + token: str + heartbeat_interval_s: float = 5.0 + + @classmethod + def from_payload( + cls, + server: str, + payload: Dict[str, Any], + ) -> "AgentState": + return cls( + server=server.rstrip("/"), + agent_id=str(payload["agent_id"]), + host_id=str(payload["host_id"]), + token=str(payload["token"]), + heartbeat_interval_s=float(payload.get("heartbeat_interval_s", 5.0)), + ) + + +class StateStore: + def __init__(self, path: Path) -> None: + self.path = path + + def load(self) -> Optional[AgentState]: + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + return AgentState(**raw) + except FileNotFoundError: + return None + except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise RuntimeError("Agent state 文件无效: {0}".format(self.path)) from exc + + def save(self, state: AgentState) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(self.path.suffix + ".tmp") + descriptor = os.open( + str(temporary), + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(asdict(state), handle, ensure_ascii=False, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, 0o600) + os.replace(temporary, self.path) + finally: + if temporary.exists(): + temporary.unlink() diff --git a/flashops/services/host-agent/tests/test_client.py b/flashops/services/host-agent/tests/test_client.py new file mode 100644 index 0000000..1461728 --- /dev/null +++ b/flashops/services/host-agent/tests/test_client.py @@ -0,0 +1,141 @@ +from pathlib import Path +import stat +import uuid + +import httpx +import pytest + +from flashops_agent.client import AgentUnauthorized, ControlPlaneClient +from flashops_agent.state import AgentState, StateStore + + +def test_control_plane_client_registers_and_sends_heartbeat(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/api/v1/agent/register": + return httpx.Response( + 201, + json={ + "agent_id": "agent_test", + "host_id": "host_test", + "token": "raw-token", + "heartbeat_interval_s": 5, + }, + ) + if request.url.path == "/api/v1/agent/agent_test/heartbeat": + return httpx.Response( + 200, + json={ + "ok": True, + "server_time": "2026-07-27T00:00:00Z", + "commands": [], + }, + ) + return httpx.Response(404) + + with ControlPlaneClient( + "http://control.test", + enrollment_token="enroll-test", + transport=httpx.MockTransport(handler), + ) as client: + registered = client.register( + { + "host_name": "pytest-host", + "agent_version": "0.1.0", + "os_family": "linux", + } + ) + heartbeat = client.heartbeat( + registered["agent_id"], + registered["token"], + {"healthy": True}, + ) + + assert heartbeat["ok"] is True + assert requests[0].headers["x-flashops-enrollment-token"] == "enroll-test" + assert requests[1].headers["authorization"] == "Bearer raw-token" + + +def test_control_plane_client_rejects_unauthorized_heartbeat(): + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"detail": "invalid"}) + + with ControlPlaneClient( + "http://control.test", + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises(AgentUnauthorized): + client.heartbeat("agent_test", "bad-token", {"healthy": True}) + + +def test_state_store_round_trip_uses_private_permissions(tmp_path: Path): + path = tmp_path / "agent-state.json" + store = StateStore(path) + expected = AgentState( + server="http://control.test", + agent_id="agent_test", + host_id="host_test", + token="raw-token", + heartbeat_interval_s=5.0, + ) + + store.save(expected) + + assert store.load() == expected + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_control_plane_client_runs_step_lease_protocol(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = request.url.path + if path.endswith("/lease"): + return httpx.Response( + 200, + json={"step_id": "step_test", "attempt": 1, "dry_run": True}, + ) + if path.endswith("/ack"): + return httpx.Response(200, json={"step_id": "step_test", "state": "RUNNING"}) + if path.endswith("/renew"): + return httpx.Response(200, json={"step_id": "step_test", "state": "RUNNING"}) + if path.endswith("/events"): + return httpx.Response(200, json={"step_id": "step_test", "accepted": 1}) + if path.endswith("/complete"): + return httpx.Response(200, json={"step_id": "step_test", "state": "SUCCEEDED"}) + return httpx.Response(404) + + token = "raw-token" + keys = [str(uuid.uuid4()) for _ in range(4)] + with ControlPlaneClient( + "http://control.test", transport=httpx.MockTransport(handler) + ) as client: + lease = client.lease("agent_test", token) + assert lease is not None + client.ack("agent_test", token, "step_test", 1, keys[0]) + client.renew("agent_test", token, "step_test", 1, keys[1]) + client.report_events( + "agent_test", + token, + "step_test", + 1, + [{"message": "hello"}], + keys[2], + ) + client.complete( + "agent_test", + token, + "step_test", + 1, + status="SUCCEEDED", + exit_code=0, + result={"simulated": True}, + idempotency_key=keys[3], + ) + + assert len(requests) == 5 + assert requests[0].headers["authorization"] == "Bearer raw-token" + assert requests[1].headers["idempotency-key"] == keys[0] diff --git a/前端UI八页面完成/.thumbnail b/前端UI八页面完成/.thumbnail new file mode 100644 index 0000000..93027b7 Binary files /dev/null and b/前端UI八页面完成/.thumbnail differ diff --git a/前端UI八页面完成/Assets.dc.html b/前端UI八页面完成/Assets.dc.html new file mode 100644 index 0000000..6d52bbe --- /dev/null +++ b/前端UI八页面完成/Assets.dc.html @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + +
+
+
+
07 · ASSETS
+

资产中心

+
登记 · 绑定 · 禁用 · 审计 — 任何破坏性任务必须绑定允许的 DUT
+
+ + +
+ +
+ + + + + +
+ + +
+ + + + + + + + + + + +
主机平台 / OSBIOSAgent心跳带外配对绑定工位状态
HOST-A01x86 台式 · Win11 22631F26b1.4.22sOOB-01WS-01运行中
HOST-A02x86 台式 · Win11 22631F26b1.4.21sOOB-02WS-02运行中
HOST-L022U 服务器 · Ubuntu 22.04 · 6.5.0-412.1a1.4.2丢失 1m+OOB-03WS-03恢复中 L3
HOST-L012U 服务器 · Ubuntu 22.04 · 6.5.0-412.1a1.4.23sOOB-04WS-04空闲
HOST-A03x86 台式 · Win11 22631F26b1.4.22sOOB-05WS-05已预约
HOST-W02x86 台式 · Win10 190451.8c1.3.9OOB-06 升级中WS-06维护
+
主机镜像:golden-image-win11-v7 / ubuntu2204-v4 · 组策略固化(禁用快速启动/自动更新/睡眠劫持) · Agent 自愈:本地看门狗 + 双进程互监,崩溃 10s 内拉起且证据不丢
+
+
+ + +
+ + + + + + + + + + + + + +
序列号型号 / 形态当前固件P/E 磨损允许动作绑定生命周期
SAMPLE-001NV-E1T92 · 1.92TB U.2FW_3.3.0-rc2 · slot2
23%
破坏写 刷写WS-01 · 运行中测试专用
SAMPLE-002NV-E1T92 · 1.92TB U.2FW_3.3.0-rc2 · slot2
31%
破坏写 刷写WS-03 · 冻结测试专用
SAMPLE-003NV-E960 · 960GB M.2FW_3.2.1 · slot1
8%
破坏写 刷写未绑定测试专用
SAMPLE-004NV-E1T92 · 1.92TB U.2FW_3.2.1 · slot1
67%
破坏写WS-02 · 运行中测试专用
SAMPLE-005NV-E1T92 · 1.92TB U.2FW_3.1.9 · slot1
89%
只读未绑定寿命预警
SAMPLE-006NV-E1T92 · 1.92TB U.2FW_3.2.1 · slot1
12%
破坏写 刷写WS-05 · 已预约测试专用
SAMPLE-007NV-E3T84 · 3.84TB U.2 工程样FW_3.3.0-rc3 · slot2
4%
刷写 只读收货检验中入库检
SAMPLE-000NV-E960 · 960GB M.2
100%
禁用已退役
+
危险任务需序列号 + BDF/设备路径 + 允许标签三信号一致才执行 · 命中系统盘立即拒绝
+
+
+ + +
+ + + + + + + + + + + +
版本SHA256来源兼容型号刷写工具审批
FW_3.3.0-rc39f2c…41aa固件组 CI #4471NV-E1T92 / E3T84vendor_flash 2.4待审批
FW_3.3.0-rc27d18…c930固件组 CI #4402NV-E1T92vendor_flash 2.4已审批
FW_3.3.0-rc155ab…0f77固件组 CI #4359NV-E1T92vendor_flash 2.4已归档
FW_3.2.14bd1…08ce发布库 · 量产基线NV-E1T92 / E960vendor_flash 2.4已审批 · 基线
FW_3.1.9e0c2…7b14发布库NV-E1T92 / E960vendor_flash 2.3已归档
FW_2.9.4-eng1a9d…3c58工程调试版NV-E3T84vendor_flash 2.4仅实验工位
+
固件哈希在登记与刷写前双重校验 · 刷写命令只能来自签名模板
+
+
+ + +
+ + + + + + + + + + + + + + + +
连接器接入方式版本risk_levelhealthcheck复用
nvme-cliCLI2.9.1 · conn 1.2只读+管理✓ 4m 前3 客户
fioCLI3.36 · conn 1.4写入✓ 4m 前3 客户
vendor_flash(私有刷写)CLI2.4 · conn 0.9固件更新✓ 12m 前本客户
MPTool 量产工具GUI-wrap5.1 · pywinauto 0.3格式化+刷写受控例外本客户
smartctlCLI7.4 · conn 1.1只读✓ 4m 前3 客户
PyNVMe3 执行器Python API24.6 · conn 0.7β写入✓ 1h 前2 客户
SANBlaze REST 桥REST API10.5 · conn 0.5β写入+注错未接入设备1 客户
blktrace 采集CLI1.3 · conn 1.0只读✓ 4m 前2 客户
私有日志抓取 log_dumpCLI1.8 · conn 0.9只读✓ 9m 前本客户
+
连接器接口:discover / precheck / execute / collect / cancel / health / normalize_result · 输入输出受 JSON Schema 约束 · 复用率目标 >60%
+
+
+ + +
+ + + + + + + + + + + +
控制器硬件固件通道能力独立心跳配对主机状态
OOB-01RPi 5 + 光耦 ATX 板oob-agent 0.9.4PWR RST HDMI 温度1sHOST-A01在线
OOB-02RPi 5 + 光耦 ATX 板oob-agent 0.9.4PWR RST 温度1sHOST-A02在线
OOB-03JetKVM + ATX 扩展adapter 0.4PWR RST KVM画面1sHOST-L02执行 L3
OOB-04PiKVM v4 + ATXadapter 0.4PWR RST KVM画面 串口2sHOST-L01在线
OOB-05RPi CM4 工业载板oob-agent 0.9.4PWR RST AC 温度1sHOST-A03在线
OOB-06RPi 5 + 光耦 ATX 板0.9.4 → 0.9.5升级中HOST-W02维护
+
带外控制器走独立网络与独立心跳,用于区分主机崩溃与网络故障 · 兼容任意商品 KVM/PDU 作执行器
+
+
+ + +
+
+ + 登记 DUT +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ + +
+
+
+
+
+
+ + + diff --git a/前端UI八页面完成/Compare.dc.html b/前端UI八页面完成/Compare.dc.html new file mode 100644 index 0000000..a25631c --- /dev/null +++ b/前端UI八页面完成/Compare.dc.html @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + +
+
+
+
05 · A/B COMPARE
+

版本比较

+
fw_ab_powerstate_regression · run-20260725-0003(A) ⇄ run-20260726-0007+复跑(B) · 报告生成于任务结束后 42s
+
+ + + +
+ +
+ +
+ +
+
执行摘要:不建议 FW_3.3.0-rc2 进入下一阶段
+
1 个阻断问题(数据完整性) · 2 个新增失败簇 · 4k 随机写性能回退超出已审核阈值
+
+ GATE · 阻断 +
+
+ 阻断 FS-0074 · DUT_DATA_FAILURE · LBA 0x2E41_0000–0x2E41_3FFF 校验失败 ×2 + 证据 run-20260726-0007 → + 失败簇 FS-0091 → +
+
发布决策由工程师作出 — 系统只提供门禁证据与风险提示 · 审阅人:__________ · 日期:2026-07-27
+
+ +
+ 环境锁定 ENV LOCK + ✓ 主机 HOST-A01/L02 同批 + ✓ BIOS F26b + ✓ OS/驱动一致 + ✓ 工具链 nvme-cli 2.9.1 · fio 3.36 + ✓ 工作流 v3 同签名 + 两次测试可比 · 无环境漂移 +
+ +
+
+ +
+ FW_3.2.1 + A · 基线 + run-20260725-0003 · WS-04 +
+
+
98%
循环通过率
+
100/100 完成 · 失败 2
自动恢复 1 · 人工介入 0
+
+
+
+
+
+
+
通过 98 恢复后通过 1 INFRA 1
+
+
+ +
+ FW_3.3.0-rc2 + B · 候选 + run-20260726-0007 + 复跑 · WS-03 +
+
+
89%
循环通过率 ▼ 9pp
+
97/100 完成 · 失败 11
自动恢复 4 · 人工介入 1
+
+
+
+
+
+
+
+
通过 89 恢复后通过 4 DUT 失败 5 INFRA 2
+
+
+ +
问题变化 · ISSUE DELTA
+
+
+ +
新增 NEW2
+
+
阻断 FS-0074 数据校验失败 ×2
DUT_DATA_FAILURE
+
DUT FS-0091 S4 唤醒掉盘 ×3
DUT_ENUMERATION_FAILURE
+
+
+
+ +
已修复 FIXED5
+
FS-0031 冷启动超时
FS-0044 SMART 读取挂起
FS-0052 Trim 后延迟尖峰
FS-0058 唤醒后链路降速
FS-0063 日志页 CRC
+
+
+ +
仍存在 PERSIST3
+
FS-0067 fio 参数兼容(SCRIPT)
FS-0071 高温限速告警
FS-0088 主机蓝屏(INFRA)
+
+
+ +
频率/severity 变化1
+
FS-0071 高温限速
A:4 次 → B:9 次 ▲ 偶发转频发
建议纳入观察名单并触发热相关单变量验证
+
+
+ +
+
+ +
性能 · 温度 · 恢复
+ + + + + + + + + + + + + + +
指标FW_3.2.1 AFW_3.3.0-rc2 BΔ判定
顺序读 MB/s7,0127,048+0.5%PASS
顺序写 MB/s6,3886,341-0.7%PASS
4k 随机读 IOPS1,148k1,131k-1.5%PASS
4k 随机写 IOPS238k218k-8.2%超阈值 -5%
P99 写延迟 µs377412+9.3%观察
S4 唤醒枚举耗时 s1.83.4 / 超时×3+89%关联 FS-0091
SMART 温度峰值 °C44.146.2+2.1PASS
自动恢复次数14×4观察
+
阈值来自已审核工作流 v3 · 全部指标可回链原始 metrics.json 与 fio 输出
+
+ +
+
+ +
下一步建议 · NEXT
+
+
最短复现
fw_ab 复现包:S4 循环 ×20 · 固定 WS-03 + SAMPLE-002 · 预计 3.5h
+
单变量验证
固定固件 B,更换主机 HOST-L01(排除 BIOS F26b 电源时序)
+
建议责任团队
固件电源管理组(FS-0091/0074) · 测试平台组(FS-0067 脚本兼容)
+
+
+
+ +
缺陷草稿 · ISSUE DRAFT
+
+
issue-draft.md · 待审阅
+
[FW_3.3.0-rc2] S4 唤醒后 PCIe 链路训练失败导致掉盘
+
复现率 3/100 · 附 Evidence Bundle ×3 · 最短复现包已生成 · 引用 kernel.log / power-actions
+
+
+
+
+
+
+
+ + + diff --git a/前端UI八页面完成/ConsoleNav.dc.html b/前端UI八页面完成/ConsoleNav.dc.html new file mode 100644 index 0000000..6930e6c --- /dev/null +++ b/前端UI八页面完成/ConsoleNav.dc.html @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + +
+ +
+ + 实验室处于安全停止状态 + 全部任务已冻结,现场证据已保留,自动重启被禁止 · 触发人 quan.zhang · {{ stopTime }} + +
+
+
+ + STORAGE LABOS + REGRESSION WORKER · 多工位控制台 + + + + + 内网部署 · 深圳一号实验室 + {{ clock }} + +
+
+ 事件总线 LIVE + 工位 5/6 在线 + 运行 2 + 恢复中 1 + 队列 3 + + UCR(7D) 96.8% + 介入密度 1.8 次/百小时 + 自动恢复成功率 87.5% + 最近人工介入 14:32 · WS-03 +
+ +
+
+ +
+ + 紧急停止 · EMERGENCY STOP +
+
+

将对全部 6 个工位执行安全停止,该动作写入不可变审计日志:

+
    +
  • 停止 2 个运行中任务(run-20260727-0001 / 0002),在当前检查点冻结
  • +
  • 中断 WS-03 正在执行的 L3 带外 Reset 恢复流程
  • +
  • 保留全部现场证据,禁止任何自动重启与供电动作
  • +
+
恢复运行需要实验室负责人审批。误触发将计入基础设施误报率。
+
+
+ + +
+
+
+
+
+
+ + + diff --git a/前端UI八页面完成/Dashboard.dc.html b/前端UI八页面完成/Dashboard.dc.html new file mode 100644 index 0000000..5044164 --- /dev/null +++ b/前端UI八页面完成/Dashboard.dc.html @@ -0,0 +1,429 @@ + + + + + + + + + + + + + + + +
+
+
+
01 · OVERVIEW
+

总览

+
深圳一号实验室 · 6 工位 · 遥测每 2s 刷新 · 2026-07-27
+
+ +
+ + + +
+
+ + +
+
+
+ + UCR · 无人值守完成率(7D) +
+ 96.8% + +
+ ▲ 0.6pp vs 上周 · 目标 98% +
+
+ + 释放工时 · HOURS FREED +
+ 41.5 h/周 + +
+ ≈ 0.9 工程师 · ¥32万/年等值 +
+
+ + 介入密度 · TOUCHES /100H · 主指标 +
+ 1.8 + +
+ ▼ 0.4 vs 上周 · 口径:任何人工触碰 +
+
+ + 自动恢复成功率 · RECOVERY +
+ 87.5% + +
+ 28/32 · L1 62% · L2 21% · L3 17% +
+
+ +
+
+
工位状态 · WORKSTATIONS 6
+
+ +
+ +
+ WS-01 + HOST-A01 · Win11 22631 + RUNNING +
+
+
fw_ab_powerstate_regression
+
run-20260727-0001 · FW_3.3.0-rc2(B) · 休眠恢复循环
+
+
+
轮次 67 / 100预计完成 23:40
+
+
+
DUT SAMPLE-001{{ t01 }}°C心跳 {{ hb01 }}
+
+ +
+ +
+ WS-02 + HOST-A02 · Win11 22631 + RUNNING +
+
+
fw_retention_72h
+
run-20260727-0002 · FW_3.2.1(A) · 高温数据保持
+
+
+
51.2 / 72 h预计完成 07-29 09:10
+
+
+
DUT SAMPLE-004{{ t02 }}°C心跳 {{ hb02 }}
+
+ +
+ +
+ WS-03 + HOST-L02 · Ubuntu 22.04 + RECOVERING · L3 +
+
+
fw_ab_powerstate_regression
+
run-20260726-0007 · FW_3.3.0-rc2(B) · 轮次 41/100 冻结
+
+
+ L1 软恢复 + + L2 OS重启 + + L3 带外RESET + + L4 AC断电 +
+
DUT SAMPLE-00243.8°C心跳丢失 {{ lost03 }}
+
+ +
+ +
+ WS-04 + HOST-L01 · Ubuntu 22.04 + IDLE +
+
空闲,可接受调度。
上一任务 run-20260725-0003 · PASSED · 100/100 循环
+
DUT 未绑定36.2°C心跳 {{ hb04 }}
+
+ +
+ +
+ WS-05 + HOST-A03 · Win11 22631 + QUEUED +
+
+
fw_coldboot_matrix
+
预约 今日 22:00 · 500 次冷启动 · FW_3.3.0-rc2(B)
+
+
DUT SAMPLE-00635.9°C心跳 {{ hb05 }}
+
+ +
+ +
+ WS-06 + HOST-W02 · Win10 19045 + OFFLINE +
+
维护窗口至 07-28 09:00
带外控制器 OOB-06 固件升级中
+
DUT 已下线心跳 —
+
+
+
+ +
+
+ +
+
需要人工处理
+ 3 +
+
+
+ UNKNOWN +
run-20260726-0011 证据不足待分类
+
+
+ 阻断 +
FW_3.3.0-rc2 存在阻断问题,需研发确认
+
+
+ 维护 +
WS-06 维护窗口今日到期,确认恢复排产
+
+
+
+ +
+ +
最近恢复动作
+
+
14:35WS-03 L3 带外 Reset 已触发进行中
+
14:33WS-03 L2 OS 重启超时(120s)失败
+
14:32WS-03 L1 Agent 软恢复无响应失败
+
02:31WS-03 心跳丢失 → L3 Reset · 4m12s成功
+
昨 23:04WS-01 L1 进程清理 · 38s成功
+
+ 全部恢复记录 → +
+ +
+ +
今日队列
+
+
22:00fw_coldboot_matrix · WS-05P1
+
23:45fw_ab 续跑 cycle 41 · WS-03P2
+
明 06:00incoming_qc_quick · WS-04P3
+
+ 任务中心 → +
+
+
+
+
+ + +
+
+ +
+ 北极星 · NORTH STAR +
96.8%
+
无人值守完成率 UCR · 近 7 天
+
▲ 0.6pp vs 上周 · 产品化目标 98%+
+
+
+
UCR 趋势 · 07-21 → 07-27计划任务 214 · 无人完成 207
+ + + + + 94 + 96 + 98 + + + + 07-21 + 07-23 + 07-25 + 07-27 + +
+
+ +
+
释放工时41.5 h/周≈ 0.9 工程师 · ¥32万/年
+
介入密度1.8 /百h主指标 · 任何触碰计 1 次
+
自动恢复成功率87.5%28/32 次失联
+
证据完整率100%关键字段零丢失
+
基础设施误报率3.1%▼ 护栏指标,单独监控
+
+ +
+
+ +
版本风险 · FIRMWARE GATE
+
+
+ FW_3.3.0-rc2 + 候选(B) + 暂缓发布 +
+
+ 阻断 1 + 新增 2 + 已修复 5 + 仍存在 3 +
+
阻断:FS-0074 数据校验失败(LBA 0x2E41xxxx) · 查看 A/B 对比 →
+
+
+ FW_3.2.1 + 基线(A) + 稳定基线 +
+
+ +
+ +
介入原因分布 · 30D
+
+
DUT_ENUMERATION_FAILURE8
+
TEST_SCRIPT_FAILURE7
+
INFRA_NETWORK_FAILURE6
+
INFRA_HOST_OS_FAILURE5
+
INFRA_AGENT_FAILURE4
+
UNKNOWN2
+
+
DUT 信号 INFRA 噪声
+
+ +
+ +
工位利用率 · 7D
+
+
WS-0192%
+
WS-0288%
+
WS-0361%
+
WS-0434%
+
WS-0512%
+
WS-060% 维护
+
+
夜间(22:00–08:00)利用率 74% · 无人值守时段占比 63%
+
+
+
+
+ + +
+
+ +
工位 RAIL
+
WS-01cycle 67/100RUN
+
WS-0251.2/72hRUN
+
WS-03L3 RESETRCVR
+
WS-04空闲IDLE
+
WS-0522:00 预约QUE
+
WS-06维护OFF
+
UCR 96.8% · 介入 1.8/百h
+
+ +
+
+ +
运行中任务 · 2
+ + + + + + + + + + + + + + + + + + + + + + +
RUN工位模板 / 固件进度心跳ETA
run-…0727-0001WS-01
fw_ab_powerstate_regression
FW_3.3.0-rc2(B)
67/100
{{ hb01 }}23:40监视
run-…0727-0002WS-02
fw_retention_72h
FW_3.2.1(A)
51/72h
{{ hb02 }}07-29监视
+
+
+ +
队列与恢复 · 3
任务中心 →
+ + + + + + + +
优先级任务工位计划时间状态
P1fw_coldboot_matrix · 500 次冷启动WS-05今日 22:00已预约
P2fw_ab 从检查点 cycle 41 续跑WS-03恢复后自动等待恢复
P3incoming_qc_quick · 来料抽检 12 盘WS-04明日 06:00已预约
+
+
+ +
+ +
事件流 · EVENT BUS
+
+
14:35:12[OOB]WS-03 RESET_SENT · 光耦通道 CH2 · 脉冲 250ms
+
14:33:40[RCVR]WS-03 L2 超时,升级 L3(策略 esc-default-v2)
+
14:32:29[EVID]WS-03 死机快照已抓取:HDMI 帧 + 串口尾部 2KB
+
14:32:07[HB]WS-03 心跳丢失 >30s,判定 HOST_UNREACHABLE
+
14:28:51[TEST]WS-01 cycle 67 数据校验 PASS · 4k_mixed 300s
+
14:25:03[TEST]WS-01 hibernate → resume 成功 · 唤醒耗时 11.2s
+
14:20:44[SYS]WS-05 预检通过:磁盘/网络/OOB/工具版本/时钟同步
+
14:18:02[SYS]FW_3.3.0-rc3.bin 已登记 · SHA256 9f2c…41aa · 待审批
+
+
+
+
+
+
+ + + diff --git a/前端UI八页面完成/Deck.dc.html b/前端UI八页面完成/Deck.dc.html new file mode 100644 index 0000000..15c37d2 --- /dev/null +++ b/前端UI八页面完成/Deck.dc.html @@ -0,0 +1,597 @@ + + + + + + + + + + + + +Industry — deck + + + + + + + + + + + diff --git a/前端UI八页面完成/Evidence.dc.html b/前端UI八页面完成/Evidence.dc.html new file mode 100644 index 0000000..a8e4f9c --- /dev/null +++ b/前端UI八页面完成/Evidence.dc.html @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + +
+
+
+
04 · EVIDENCE
+

证据浏览器

+
失败时间线 · Evidence Bundle · 每个结论回链到原始证据
+
+ + + + +
+ +
+
+ +
+
失败记录 · 近 7 天
+ 3 / 214 +
+ +
+ +
+
+ {{ f.run }} + {{ f.when }} +
+
{{ f.code }}
+
{{ f.summary }}
+
{{ f.meta }}
+
+
+
+
+ DUT_* + INFRA_* + UNKNOWN +
+
+ +
+
+ +
+ {{ selRun }} + {{ selCode }} + 指纹 {{ selFs }} + {{ selMeta }} +
+
{{ selHeadline }}
+
+ HOST-L02 · Ubuntu 22.04 · 6.5.0-41 + BIOS F26b + FW_3.3.0-rc2 · slot 2 + nvme-cli 2.9.1 · fio 3.36 + workflow v3 · sig 8e02…c4 +
+
+ +
+ +
事件时间线 · TIMELINE
+
+ +
+ {{ ev.t }} +
+ + +
+
+
+ {{ ev.lane }} + {{ ev.title }} +
+
{{ ev.detail }}
+ +
+ + {{ lk }} + +
+
+
+
+
+
+
+ +
+ +
+
AI 摘要 · 仅引用证据,不做最终判断
+ 置信度 0.82 · 本地模型 +
+
S4 唤醒后 DUT 未完成链路训练即被访问:kernel 在唤醒后 3s 内未出现 nvme 重枚举日志 kernel.log:L1042-1067,而基线固件 A 平均 1.8s 完成 metrics.json#resume_enum。与 FS-0091 簇内 11 次历史失败特征一致(均为 S4 唤醒 + rc 系列固件)。
+
尚不能排除:主板 BIOS F26b 的 S4 电源时序差异(建议单变量验证:同盘同固件换 HOST-L01 复跑 20 循环)。
+
+
+ +
+
+ +
+
EVIDENCE BUNDLE
+ run-20260726-0007/ +
+
+ +
+ {{ n.chev }} + {{ n.name }} + + 关键 + + {{ n.size }} +
+
+
+
manifest 校验通过 · 42 文件 · SHA256 全部一致
生成组件 evidence-bus 0.9.4 · 不可覆盖
+
+
+ +
恢复过程 · RECOVERY.JSON
+
+
L1 Agent 软恢复FAIL · 60s
+
L2 OS 通道重启FAIL · 120s
+
L3 带外 ResetOK · 2m17s
+
总耗时 / 介入4m12s · 无人
+
+ 回放当时的实时视图 → +
+
+
+
+
+ + + diff --git a/前端UI八页面完成/Failures.dc.html b/前端UI八页面完成/Failures.dc.html new file mode 100644 index 0000000..48934f2 --- /dev/null +++ b/前端UI八页面完成/Failures.dc.html @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + +
+
+
+
06 · FAILURES
+

失败中心

+
跨任务聚类 · 失败指纹由稳定字段构成,工程师可拆分/合并/命名,结果反哺规则
+
+ + +
+ + + + + +
+
+ +
+ 视觉语言 + DUT_* 实心 — 真实信号,计入版本结论 + INFRA_* 斜线 — 基础设施噪声,单独监控误报率 + SCRIPT 灰 — 脚本/参数问题 + UNKNOWN 虚线 — 证据不足,等待分类 + 阻断 — 门禁级 +
+ +
+ +
+ 指纹状态码摘要次数 · 14D首次 / 最近关联版本状态 +
+ +
+
+ {{ r.chev }} + {{ r.id }} + {{ r.code }} + {{ r.summary }}阻断 + {{ r.count }} + + + {{ r.first }}
{{ r.last }}
+ + {{ v }} + + {{ r.status }} +
+ +
+
+
指纹特征字段 · SIGNATURE
+
+
{{ ft.k }}{{ ft.v }}
+
+
+
+
关联运行 · {{ r.count }} 次
+
+ {{ rn }} +
+
+
+
+
最短复现 · REPRO
+
{{ r.repro }}
+
+
+ + + + +
+
+
+
+
+
+
+
+ 聚类由归一化结构字段计算签名,语义模型仅辅助合并建议 + 30 天:去重后 7 簇 ← 43 次原始失败 · 重复问题合并率 84% +
+
+
+ + + diff --git a/前端UI八页面完成/LiveRun.dc.html b/前端UI八页面完成/LiveRun.dc.html new file mode 100644 index 0000000..0265d05 --- /dev/null +++ b/前端UI八页面完成/LiveRun.dc.html @@ -0,0 +1,450 @@ + + + + + + + + + + + + + + + +
+
+
+
03 · LIVE RUN
+

实时运行

+
{{ hostName }} · 只在必须时人工接管
+
+ + +
+ + +
+
+ +
+ +
+
{{ runId }}
+
fw_ab_powerstate_regression v3 · 已审核模板
+
+ +
固件 {{ firmwareLabel }}
DUT {{ dutLabel }}
+ +
开始 {{ startText }}
检查点 {{ checkpointText }}
+ +
+
轮次 {{ cycle }} / {{ target }}由控制平面实时投影
+
+
+ {{ badgeText }} + + + +
+ + +
+ {{ hostName }} 已安全停止 + 任务已安全停止,现场与检查点已保留 · 已写入不可变审计事件 + +
+
+ + +
+
+ +
步骤执行器 · WORKFLOW
+
lab.precheck09:12
+
vendor.flash_firmware B09:14
+
host.reboot · 版本确认09:18
+
+
loop · repeat {{ target }}cycle {{ cycle }}
+
nvme.collect_baseline
+
io.run_profile 4k_mixed{{ ioNote }}
+
host.hibernate
+
host.wait_resume
+
dut.verify_enumeration
+
data.verify_hash
+
+
+
ON_FAILURE
+
evidence.capture_all
recovery.escalate
failure.classify
+
+
PASS 66FAIL 0自动恢复 1介入 0
+
+ +
+
+ +
+
实时日志流 · EVENT LOG
+ + ALL + CMD + OOB + WARN + +
+
+ +
{{ ln.t }}{{ ln.lane }}{{ ln.text }}
+
+
+
+
+
+ +
HDMI 采集画面
{{ hdmiNote }}
+
+ +
+
采集卡 CAP-01 · 1080p@5fps · OS 未启动/蓝屏时仍可留证
+
+
+ +
遥测 · TELEMETRY
+
+
HOST 心跳
{{ hbText }}
+
DUT 温度
{{ temp }}°C
+
设备枚举
nvme0n1 · Gen4 x4
+
供电 · OOB
AC ON · ATX ON
+
+
+
温度 30min阈值 55°C
+ +
+
+
+
+ +
+
+ +
分级恢复阶梯 · ESCALATION
esc-default-v2
+
+ +
+ {{ lv.num }} +
+
{{ lv.name }}
+
{{ lv.desc }}
+
+ {{ lv.state }} +
+
+
+
恢复前先取证 · 多次失败进入安全停止,禁止无限重启
+
+
+ +
本轮关键指标 · CYCLE {{ cycle }}
+
+
4k_mixed IOPS218,430
+
P99 延迟412 µs
+
唤醒耗时11.2 s
+
SMART 温度峰值46°C
+
校验哈希一致 ✓
+
+ 打开本任务证据 → +
+
+
+
+ + +
+
+ +
执行时间线 · 14:20 → NOW
每格 2 分钟 · cycle 66–67
+
+
TEST
+
+
io 4k_mixed
+
hib
+
res
+
枚举
+
verify
+
cycle 67 · io 4k_mixed RUNNING
+
+
HOST
+
+
ONLINE · hb 1-3s
+
S4 休眠
+
ONLINE · resume 11.2s
+
+
DUT
+
+
nvme0n1 Gen4 x4 · 42-46°C
+
+
重新枚举 1.8s · SMART 正常
+
+
OOB
+
+
待命 · 心跳监测中 · 无供电动作
+
+
+
+
测试步骤 电源状态 链路事件│ 播放头 NOW
+
+
+
+ +
日志尾部 · TAIL
+
+ +
{{ ln.t }}{{ ln.lane }}{{ ln.text }}
+
+
+
+
+ +
恢复阶梯
+
+ +
+ {{ lv.num }} +
{{ lv.name }}
+ {{ lv.state }} +
+
+
+
+
+
+
+
+
+ + + diff --git a/前端UI八页面完成/Tasks.dc.html b/前端UI八页面完成/Tasks.dc.html new file mode 100644 index 0000000..0c8c978 --- /dev/null +++ b/前端UI八页面完成/Tasks.dc.html @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + +
+
+
+
02 · TASKS
+

任务中心

+
创建 · 队列 · 预约 · 破坏性任务必须绑定允许的 DUT 序列号
+
+ + 运行 2 + 队列 3 + 今日完成 4 +
+ +
+ +
+
创建任务 · NEW RUN
+ 固件 A/B 循环回归 + 工作流签名 8e02…c4 · 已审核 v3 +
+
+ +
+
01资产绑定
+
+ +
+
+ +
+
SN S6XNNA0T609 · 允许破坏性写入
P/E 磨损 12% · 生命周期 测试专用
带外 OOB-05 已配对 ✓
+
+ +
+
02固件 A / B
+
+ +
+
+ +
+
A sha256 4bd1…08ce ✓
B sha256 9f2c…41aa ✓
B 尚未审批 — 启动前需固件负责人批准
+
+ +
+
03模板与参数
+
+ +
+
+
+
+
+
+ +
+
+
+ +
+
04安全与预检
+ + +
+
环境预检 · PRECHECK{{ pcNote }}
+
+ +
{{ ck.icon }}{{ ck.name }}{{ ck.note }}
+
+
+
+ +
✓ 已入队 {{ startedRun }} · WS-05 · 正在执行 · 跟踪 →
+
+
+ + +
+
+
+
+ +
+
+ +
队列 · QUEUE 3
调度策略:优先级 + 维护窗口避让
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
任务工位 / DUT计划状态
P1
fw_coldboot_matrix · 500 次冷启动
FW_3.3.0-rc2(B)
WS-05 · SAMPLE-006今日 22:00已预约
P2
fw_ab 从检查点续跑 · cycle 41→100
FW_3.3.0-rc2(B)
WS-03 · SAMPLE-002恢复后自动等待恢复
P3
incoming_qc_quick · 来料抽检 12 盘
批次 B2607-11
WS-04 · 批量明日 06:00已预约
+
+ +
+ +
最近完成 · RECENT
全部报告 →
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RUN模板结果循环介入报告
run-…0726-0007fw_ab_powerstateFAILED_DUT97/1001证据 · A/B
run-…0726-0011fw_retention_72h 段2UNKNOWN50/500证据
run-…0725-0003fw_ab_powerstatePASSED100/1000A/B
run-…0725-0018fw_coldboot_matrixFAILED_INFRA88/1000证据
+
+
+
+
+ + + diff --git a/前端UI八页面完成/Workflows.dc.html b/前端UI八页面完成/Workflows.dc.html new file mode 100644 index 0000000..7bc6e37 --- /dev/null +++ b/前端UI八页面完成/Workflows.dc.html @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + +
+
+
+
08 · WORKFLOWS
+

工作流模板

+
SOP 结构化 · 版本化 · 审批与签名 — 运行时禁止拼接未审核的危险命令
+
+ + + +
+ +
+
+ +
+
+ {{ tp.name }} + {{ tp.ver }} +
+
{{ tp.desc }}
+
+ {{ tp.status }} + {{ rk }} + {{ tp.updated }} +
+
+
+
每接入一套真实 SOP,就沉淀为可复用模板与连接器 · 模板参数化,严格控制定制边界
+
+ +
+
+ +
+

{{ selName }}

+ {{ selStatus }} + + + + +
+
+ {{ selVer }} · workflow_version 1 + 签名 {{ selSig }} + 来源 {{ selSource }} + 检查点:每循环 + 恢复策略 esc-default-v2 +
+
+ +
+
+ +
步骤定义 · STEPS
+ +
+ {{ st.num }} +
+
+ {{ st.name }} + {{ st.risk }} +
+
{{ st.note }}
+
+ {{ st.meta }} +
+
+
+
ON_FAILURE · 失败处理管道
+
+ evidence.capture_all + + recovery.escalate + + failure.classify + 取证先于恢复 · 破坏性步骤不自动重试 +
+
+
+ +
+
+ +
安全声明 · SAFETY
+
safety:
  allowed_dut_serials: {{ selSerials }}
  destructive_write: {{ selDestr }}
  require_approval: true
  system_disk_guard: enforce
variables:
  cycles: {{ selCycles }}
  firmware: "${firmware}"
+
Format / Sanitize / 刷写只能来自签名模板,参数受 Schema 限制
+
+
+ +
审批记录 · AUDIT
+
+ +
+ {{ ap.t }} + + {{ ap.what }} + {{ ap.who }} +
+
+
+
+
+
+
+
+
+
+ + + diff --git a/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_adherence.oxlintrc.json b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_adherence.oxlintrc.json new file mode 100644 index 0000000..3b11af9 --- /dev/null +++ b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_adherence.oxlintrc.json @@ -0,0 +1,152 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Barlow|Barlow Condensed))/i]", + "message": "Font not provided by the design system. Available: Barlow, Barlow Condensed." + } + ] + }, + "overrides": [ + { + "files": [ + "**/index.js" + ], + "rules": { + "no-restricted-imports": "off" + } + } + ], + "x-omelette": { + "components": {}, + "tokens": [ + "--color-accent", + "--color-accent-100", + "--color-accent-2", + "--color-accent-2-100", + "--color-accent-2-200", + "--color-accent-2-300", + "--color-accent-2-400", + "--color-accent-2-500", + "--color-accent-2-600", + "--color-accent-2-700", + "--color-accent-2-800", + "--color-accent-2-900", + "--color-accent-200", + "--color-accent-300", + "--color-accent-400", + "--color-accent-500", + "--color-accent-600", + "--color-accent-700", + "--color-accent-800", + "--color-accent-900", + "--color-bg", + "--color-divider", + "--color-neutral-100", + "--color-neutral-200", + "--color-neutral-300", + "--color-neutral-400", + "--color-neutral-500", + "--color-neutral-600", + "--color-neutral-700", + "--color-neutral-800", + "--color-neutral-900", + "--color-surface", + "--color-text", + "--font-body", + "--font-heading", + "--font-heading-weight", + "--radius-lg", + "--radius-md", + "--radius-sm", + "--shadow-lg", + "--shadow-md", + "--shadow-sm", + "--space-1", + "--space-2", + "--space-3", + "--space-4", + "--space-6", + "--space-8" + ], + "tokenKinds": { + "--color-bg": "color", + "--color-surface": "color", + "--color-text": "font", + "--color-accent": "color", + "--color-accent-2": "color", + "--color-divider": "color", + "--color-neutral-100": "color", + "--color-neutral-200": "color", + "--color-neutral-300": "color", + "--color-neutral-400": "color", + "--color-neutral-500": "color", + "--color-neutral-600": "color", + "--color-neutral-700": "color", + "--color-neutral-800": "color", + "--color-neutral-900": "color", + "--color-accent-100": "color", + "--color-accent-200": "color", + "--color-accent-300": "color", + "--color-accent-400": "color", + "--color-accent-500": "color", + "--color-accent-600": "color", + "--color-accent-700": "color", + "--color-accent-800": "color", + "--color-accent-900": "color", + "--color-accent-2-100": "color", + "--color-accent-2-200": "color", + "--color-accent-2-300": "color", + "--color-accent-2-400": "color", + "--color-accent-2-500": "color", + "--color-accent-2-600": "color", + "--color-accent-2-700": "color", + "--color-accent-2-800": "color", + "--color-accent-2-900": "color", + "--font-heading": "font", + "--font-heading-weight": "font", + "--font-body": "font", + "--space-1": "spacing", + "--space-2": "spacing", + "--space-3": "spacing", + "--space-4": "spacing", + "--space-6": "spacing", + "--space-8": "spacing", + "--radius-sm": "radius", + "--radius-md": "radius", + "--radius-lg": "radius", + "--shadow-sm": "shadow", + "--shadow-md": "shadow", + "--shadow-lg": "shadow" + }, + "fontFamilies": [ + "Barlow", + "Barlow Condensed" + ] + } +} \ No newline at end of file diff --git a/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js new file mode 100644 index 0000000..af8ca74 --- /dev/null +++ b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_bundle.js @@ -0,0 +1,11 @@ +/* @ds-bundle: {"format":4,"namespace":"Industry_indust","components":[],"sourceHashes":{},"inlinedExternals":[],"unexposedExports":[]} */ + +(() => { + +const __ds_ns = (window.Industry_indust = window.Industry_indust || {}); + +const __ds_scope = {}; + +(__ds_ns.__errors = __ds_ns.__errors || []); + +})(); diff --git a/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_manifest.json b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_manifest.json new file mode 100644 index 0000000..645b78a --- /dev/null +++ b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/_ds_manifest.json @@ -0,0 +1 @@ +{"namespace":"Industry_indust","components":[],"startingPoints":[],"cards":[{"path":"components/buttons.html","group":"Components","viewport":"640x460","subtitle":"Solid primary actions; tags tint from the ramps","name":"Buttons & tags"},{"path":"components/cards.html","group":"Components","viewport":"640x420","subtitle":"Transparent, corner-marked line drawings with a kicker, title, body and meta row","name":"Cards"},{"path":"components/dialog.html","group":"Components","viewport":"640x460","subtitle":"A modal surface at the top elevation, shown here inside a static frame","name":"Dialog"},{"path":"components/forms.html","group":"Components","viewport":"640x520","subtitle":"Text inputs, a segmented control and radios — native elements, themed states","name":"Forms"},{"path":"components/navigation.html","group":"Components","viewport":"640x420","subtitle":"A header bar over a grid page opening","name":"Navigation"},{"path":"components/table.html","group":"Components","viewport":"640x420","subtitle":"Rows separated by a whisper of the text color; status cells reuse the tags","name":"Table"},{"path":"foundations/color.html","group":"Foundations","viewport":"640x560","subtitle":"Role colors and the tonal ramps (mono scheme — one accent voice)","name":"Color"},{"path":"foundations/icons.html","group":"Foundations","viewport":"640x420","subtitle":"Lucide — inline SVG on currentColor","name":"Icons"},{"path":"foundations/image.html","group":"Foundations","viewport":"640x480","subtitle":"Duotone — photographs are washed in the accent, like a screen print.","name":"Imagery"},{"path":"foundations/layout.html","group":"Foundations","viewport":"640x560","subtitle":"A 0.85× spacing scale, 4px radius, and three shadow levels tuned to the light ground","name":"Spacing & elevation"},{"path":"foundations/type.html","group":"Foundations","viewport":"640x640","subtitle":"Barlow Condensed over Barlow — a fixed scale; density moves spacing, not sizes","name":"Typography"},{"path":"theme.html","group":"Theme","viewport":"640x520","subtitle":"The parameters this system was derived from","name":"Parameters"}],"templates":[{"name":"Deck","description":"A twenty-one-slide presentation starter: cover, contents, dividers, columns, quadrants, a data table, SVG charts and timeline, bleed imagery, a quote and a close, on the theme's tokens","folder":"templates/deck","entryPath":"templates/deck/Deck.dc.html"},{"name":"Landing","description":"A one-page product landing in the system's own voice — an invented product (Holdfast: a fastener catalog), a condensed hero, a numbered spec-sheet plate, wireframe cells and a duotone photograph","folder":"templates/landing","entryPath":"templates/landing/index.html"}],"hasThumbnailHtml":true,"globalCssPaths":["styles.css"],"tokens":[{"name":"--color-bg","value":"#f2f2f3","kind":"color","definedIn":"styles.css"},{"name":"--color-surface","value":"#e9e9ea","kind":"color","definedIn":"styles.css"},{"name":"--color-text","value":"#1d1f20","kind":"font","definedIn":"styles.css"},{"name":"--color-accent","value":"#5980a6","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2","value":"#728fab","kind":"color","definedIn":"styles.css"},{"name":"--color-divider","value":"color-mix(in srgb, #1d1f20 16%, transparent)","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-100","value":"#f5f5f8","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-200","value":"#e7e7ea","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-300","value":"#d4d4d7","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-400","value":"#b7b7ba","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-500","value":"#98989b","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-600","value":"#7a7a7d","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-700","value":"#5d5d60","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-800","value":"#424244","kind":"color","definedIn":"styles.css"},{"name":"--color-neutral-900","value":"#2b2b2d","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-100","value":"#eef6ff","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-200","value":"#d6ebff","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-300","value":"#b5d9fd","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-400","value":"#94bce3","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-500","value":"#749dc4","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-600","value":"#597ea3","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-700","value":"#416180","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-800","value":"#2c455d","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-900","value":"#1d2d3d","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-100","value":"#eef6ff","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-200","value":"#d6ebff","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-300","value":"#bdd8f2","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-400","value":"#9ebbd8","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-500","value":"#7e9cb8","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-600","value":"#627d98","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-700","value":"#486077","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-800","value":"#314457","kind":"color","definedIn":"styles.css"},{"name":"--color-accent-2-900","value":"#1f2d3a","kind":"color","definedIn":"styles.css"},{"name":"--font-heading","value":"\"Barlow Condensed\", system-ui, sans-serif","kind":"font","definedIn":"styles.css"},{"name":"--font-heading-weight","value":"600","kind":"font","definedIn":"styles.css"},{"name":"--font-body","value":"\"Barlow\", system-ui, sans-serif","kind":"font","definedIn":"styles.css"},{"name":"--space-1","value":"3.4px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-2","value":"6.8px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-3","value":"10.2px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-4","value":"13.6px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-6","value":"20.4px","kind":"spacing","definedIn":"styles.css"},{"name":"--space-8","value":"27.2px","kind":"spacing","definedIn":"styles.css"},{"name":"--radius-sm","value":"2px","kind":"radius","definedIn":"styles.css"},{"name":"--radius-md","value":"4px","kind":"radius","definedIn":"styles.css"},{"name":"--radius-lg","value":"7px","kind":"radius","definedIn":"styles.css"},{"name":"--shadow-sm","value":"0 1px 2px color-mix(in srgb, #2b2b2d 14%, transparent)","kind":"shadow","definedIn":"styles.css"},{"name":"--shadow-md","value":"0 3px 10px color-mix(in srgb, #2b2b2d 16%, transparent)","kind":"shadow","definedIn":"styles.css"},{"name":"--shadow-lg","value":"0 12px 32px color-mix(in srgb, #2b2b2d 22%, transparent)","kind":"shadow","definedIn":"styles.css"}],"themes":[],"fonts":[],"brandFonts":[{"family":"Barlow Condensed","status":"ok","tokens":["--font-heading"],"path":"styles.css"},{"family":"Barlow","status":"ok","tokens":["--font-body"],"path":"styles.css"}],"source":"spa"} \ No newline at end of file diff --git a/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/readme.md b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/readme.md new file mode 100644 index 0000000..7d8749e --- /dev/null +++ b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/readme.md @@ -0,0 +1,82 @@ +# Industry design system + +Industry is a wireframe: steel-blue on a light technical ground, Barlow Condensed headings over Barlow, a modular grid, and cards, figures and buttons framed as blueprint objects — square-cornered, hairline-bordered, with "+" registration marks at the corners. Cards and figures stay transparent line drawings; the primary button is the one solid object on the board, an accent fill that keeps the square corners and the marks. Photography is duotoned into the steel accent and icons are thin-stroke. + +## How to use this + +- Link the one stylesheet from every page — `` (adjust the relative path) — and take every color, font, spacing, radius and shadow from its variables (`var(--color-*)`, `var(--font-*)`, `var(--space-*)`, `var(--radius-*)`, `var(--shadow-*)`). Never hard-code a hex, a font name or a px value the tokens already carry. +- Build with the classes below rather than inventing parallel ones; the component pages are plain HTML, so view source and copy the markup. +- `templates/` holds starting points a consuming project can copy whole. +- The whole system was derived from `theme.json`. To change the look, edit the tokens at the top of `styles.css` — every page, the thumbnail and this guide read from them — and keep `theme.json` and the written guidance in step so they don't drift from what the CSS actually does. + +## Direction + +Modular grid layouts — content in equal-width cells, strong horizontal and vertical rhythm, visible structure. Cards, buttons and major sections are wireframe objects: square-cornered, thin-bordered, with `+` crosshair corner marks (the `.blueprint` class + four `` children) — never soft filled rounded blocks. Images and figures get the same treatment: square, hairline-framed and marked, never rounded or clipped. Wrap hero and inline images in the `.duotone` class — they are desaturated and washed in the accent, like a screen print that re-colors with the theme. + +## Color + +A light ground (`--color-bg` #f2f2f3) with `--color-text` #1d1f20 and a single accent #5980a6 (this is a mono scheme: no second accent was chosen — the `--color-accent-2-*` variables carry a machine-derived stand-in kept only so both sets resolve; treat them as one role). Each role carries a 100–900 tonal ramp (`--color-neutral-100` … `--color-accent-2-900`) generated in OKLCH on a shared perceptual lightness scale, so the same step of any ramp has the same visual weight. Use the light steps (100–300) for tinted fills, hovers and subtle borders, 500 as the role's base, and the dark steps (700–900) for text on tinted fills and for pressed states; prefer ramp steps over ad-hoc `color-mix()`. For elevation use `--shadow-sm/md/lg` (already tuned to the ground) rather than ad-hoc box-shadows. + +## Type + +Barlow Condensed for headings over Barlow for body text, loaded as `--font-heading` / `--font-body`. Density 0.85× and radius 4px are already baked into the `--space-*` / `--radius-*` scales — use the variables, not raw numbers. + +## Icons + +Use Lucide icons (https://lucide.dev), at stroke-width 1.5 for a lighter, more technical look throughout. + +## Interaction states + +Interactive states are themed, never browser defaults: give every interactive element a `:hover` tint and a pressed state from the accent ramp (one step past the base — `--color-accent-600` on a light ground, `--color-accent-400` on a dark one, or a `color-mix()` tint for outlined/ghost variants), and style keyboard focus with `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }` — never leave the default blue focus ring. + +## Components + +| Class | What it is | Shown in | +| --- | --- | --- | +| `.btn` with `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.btn-icon`, `.btn-block` | Actions — the primary is a solid accent fill | components/buttons.html | +| `.tag` with `.tag-accent`, `.tag-accent-2`, `.tag-neutral`, `.tag-outline` | Small labels tinted from the ramps (mono palette: accent-2 reads the same as accent) | components/buttons.html | +| `.field` + `label`, `.input`, `.radio` + `.dot`, `.seg` + `.seg-opt` | Form fields and choices on native elements — no script | components/forms.html | +| `.card` with `.card-kicker`, `.card-title`, `.card-body`, `.card-meta`; `.elev-sm/md/lg` | Transparent, hairline-bordered cards with corner registration marks | components/cards.html | +| `.nav` + `.nav-brand` | The header bar | components/navigation.html | +| `.table` | Data tables with themed header and row rules | components/table.html | +| `.dialog-backdrop` + `.dialog` (+ `.dialog-title/-body/-actions`) | A modal at the top elevation | components/dialog.html | +| `.hr` | A horizontal rule — present, but this system prefers whitespace; avoid it | — | +| `.blueprint` + four `` children | The wireframe frame every card, figure and primary button wears | components/cards.html | +| `.duotone` | The image wrapper — every content photograph goes through it | foundations/image.html | + +States are built in: hovers and pressed states come from the accent ramp, keyboard focus is the 2px accent `:focus-visible` ring, `::selection` is an accent tint, and disabled controls drop to 45% opacity. Don't restyle them per page. The accent-to-ground pair is tuned to at least 3:1 — enough for icons, large text and interface chrome, not for body copy — so for paragraph-size text in the accent use a deep ramp step (`--color-accent-700` on this ground) rather than the accent itself. + +## Do + +- Frame cards, figures and primary buttons as blueprint objects: the `.blueprint` class plus four `` marks. +- Keep the grid visible — equal cells, strong horizontal and vertical rhythm. +- Condense headings (Barlow Condensed) and keep body copy in Barlow. +- Duotone photographs with the `.duotone` wrapper so they take the accent. + +## Don't + +- Do not round cards, figures or buttons, and do not give cards or figures a surface fill — they are line drawings (the solid accent primary button is the one deliberate exception). +- Do not drop the registration marks from a framed element. +- Do not use thick icon strokes; the set is Lucide at 1.5. +- Do not add decorative color beyond the steel accent. The accent's own deep step (`--color-accent-900`) may carry a full field where the deck's section dividers use it — steel as ground, type reversed to paper. (The landing's numbers sit on a drawn spec-sheet plate on the paper ground instead — its own grammar, not a field.) + +## Files + +- `styles.css` — the only stylesheet: the token sheet (`:root` variables, ramps, base type) plus the component layer. Link it from every page. +- `readme.md` — this guide. +- `theme.json` — the parameters these files were derived from (a machine-readable record of the theme). +- `thumbnail.html` — the project cover (brand mark + swatches). +- `foundations/type.html` — the type scale and the heading/body pairing at real sizes. +- `foundations/color.html` — color roles and the 100-900 tonal ramps, with usage notes. +- `foundations/layout.html` — the spacing scale, the grid and how edges are drawn. +- `foundations/icons.html` — the icon set at interface sizes, inline and in buttons. +- `foundations/image.html` — how photographs and figures are treated. +- `components/buttons.html` — buttons, icon buttons and tags in every variant and state. +- `components/forms.html` — text fields, radios and the segmented control on native elements. +- `components/cards.html` — content cards and the elevation steps. +- `components/navigation.html` — the header bar pattern. +- `components/table.html` — a data table with the themed header and row rules. +- `components/dialog.html` — a modal over its backdrop at the top elevation. +- `theme.html` — the theme's parameters rendered as a reference sheet. +- `templates/landing/` — a starter page consuming the system the intended way (`index.html`, its `ds-base.js` loader, and the vendored `image-slot.js` its photograph mounts). +- `assets/photo.jpg` — the reference photograph the imagery page treats. diff --git a/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css new file mode 100644 index 0000000..82d4e15 --- /dev/null +++ b/前端UI八页面完成/_ds/industry-22c874fd-9888-4e99-8234-d60d39b3d719/styles.css @@ -0,0 +1,285 @@ +/* Industry — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */ +@import url('https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;700&family=Barlow+Condensed:wght@400;600&display=swap'); + +:root { + --color-bg: #f2f2f3; + --color-surface: #e9e9ea; + --color-text: #1d1f20; + --color-accent: #5980a6; + --color-accent-2: #728fab; + --color-divider: color-mix(in srgb, #1d1f20 16%, transparent); + + /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the + same step of any role matches the others in visual value. */ + --color-neutral-100: #f5f5f8; + --color-neutral-200: #e7e7ea; + --color-neutral-300: #d4d4d7; + --color-neutral-400: #b7b7ba; + --color-neutral-500: #98989b; + --color-neutral-600: #7a7a7d; + --color-neutral-700: #5d5d60; + --color-neutral-800: #424244; + --color-neutral-900: #2b2b2d; + + --color-accent-100: #eef6ff; + --color-accent-200: #d6ebff; + --color-accent-300: #b5d9fd; + --color-accent-400: #94bce3; + --color-accent-500: #749dc4; + --color-accent-600: #597ea3; + --color-accent-700: #416180; + --color-accent-800: #2c455d; + --color-accent-900: #1d2d3d; + + --color-accent-2-100: #eef6ff; + --color-accent-2-200: #d6ebff; + --color-accent-2-300: #bdd8f2; + --color-accent-2-400: #9ebbd8; + --color-accent-2-500: #7e9cb8; + --color-accent-2-600: #627d98; + --color-accent-2-700: #486077; + --color-accent-2-800: #314457; + --color-accent-2-900: #1f2d3a; + + --font-heading: "Barlow Condensed", system-ui, sans-serif; + --font-heading-weight: 600; + --font-body: "Barlow", system-ui, sans-serif; + + --space-1: 3.4px; + --space-2: 6.8px; + --space-3: 10.2px; + --space-4: 13.6px; + --space-6: 20.4px; + --space-8: 27.2px; + + --radius-sm: 2px; + --radius-md: 4px; + --radius-lg: 7px; + + /* Elevation — derived from the ground: soft ink-tinted shadows on a + light theme, a hairline edge + ambient darkness on a dark one. */ + --shadow-sm: 0 1px 2px color-mix(in srgb, #2b2b2d 14%, transparent); + --shadow-md: 0 3px 10px color-mix(in srgb, #2b2b2d 16%, transparent); + --shadow-lg: 0 12px 32px color-mix(in srgb, #2b2b2d 22%, transparent); +} + +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); +} +h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); } + +.blueprint { + position: relative; + border: 1px solid var(--color-divider); + border-radius: 0; +} +/* The overlay image treatments (halftone, duotone) clip their overlay + (overflow:hidden); a blueprint wrapper draws its registration marks + outside the box, so when both classes share a wrapper the frame must + win. */ +.blueprint.halftone, .blueprint.plate, .blueprint.duotone { overflow: visible; } +.blueprint > .corner { + position: absolute; width: 11px; height: 11px; + color: color-mix(in srgb, var(--color-text) 55%, transparent); +} +.blueprint > .corner::before, .blueprint > .corner::after { + content: ""; position: absolute; background: currentColor; +} +.blueprint > .corner::before { left: 5px; top: 0; width: 1px; height: 100%; } +.blueprint > .corner::after { top: 5px; left: 0; width: 100%; height: 1px; } +.blueprint > .corner.tl { top: -6px; left: -6px; } +.blueprint > .corner.tr { top: -6px; right: -6px; } +.blueprint > .corner.bl { bottom: -6px; left: -6px; } +.blueprint > .corner.br { bottom: -6px; right: -6px; } + +.duotone{position:relative;overflow:hidden} +.duotone::after{content:"";position:absolute;inset:0;pointer-events:none; + background:var(--color-accent);mix-blend-mode:color} + +/* ══════════════════════════════════════════════════════════════════════════ + Components — built with the tokens above. Plain CSS + on plain HTML: no JavaScript, no build step. Each class is documented in + readme.md and demonstrated in foundations/ and components/. + ══════════════════════════════════════════════════════════════════════ */ + +*, *::before, *::after { box-sizing: border-box; } +body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; } +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2); +} +h1 { font-size: 42px; } +h2 { font-size: 32px; } +h3 { font-size: 25px; } +h4 { font-size: 20px; } +h5 { font-size: 16px; } +h6 { font-size: 13px; } +h6 { letter-spacing: 0.08em; text-transform: uppercase; } +p { margin: 0 0 var(--space-3); } +a { color: var(--color-accent); text-underline-offset: 3px; } +img { display: block; max-width: 100%; } +figure { margin: 0; } +figcaption { + font-size: 11px; margin-top: var(--space-1); + color: color-mix(in srgb, var(--color-text) 55%, transparent); +} +.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); } +:focus { outline: none; } +:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } +::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); } + +/* — rules — */ +.hr { + height: 1px; border: 0; margin: var(--space-4) 0; + background: var(--color-divider); +} + +/* — buttons — */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + cursor: pointer; text-decoration: none; + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px — + the pair sits side by side in sign-up rows */ + background: transparent; border: 1px solid transparent; + padding: var(--space-2) calc(var(--space-3) * 1.2); + border-radius: var(--radius-md); +} +.btn svg { display: block; } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } +.btn-primary { background: var(--color-accent); color: var(--color-bg); } +.btn-primary:hover { background: var(--color-accent-600); } +.btn-primary:active { background: var(--color-accent-700); } +.btn-secondary { border-color: var(--color-divider); } +.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } +.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); } +.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); } +.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); } +.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); } +.btn-icon { width: 36px; height: 36px; padding: 0; } +.btn-block { width: 100%; margin-top: var(--space-2); } + +/* — forms — */ +.field > label { + display: block; font-size: 12px; margin-bottom: 5px; + color: color-mix(in srgb, var(--color-text) 70%, transparent); +} +.input { + width: 100%; min-height: 36px; padding: 6px 10px; font: inherit; + font-size: 14px; color: var(--color-text); caret-color: var(--color-accent); + background: var(--color-surface); + border: 1px solid var(--color-divider); border-radius: var(--radius-md); +} +.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); } +.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; } +textarea.input { min-height: 90px; resize: vertical; } +.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; } +.radio input, .seg-opt input { + position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; +} +.radio .dot { + width: 16px; height: 16px; flex: none; border-radius: 50%; + border: 1.5px solid var(--color-divider); +} +.radio:hover .dot { border-color: var(--color-accent); } +.radio input:checked + .dot { + border-color: var(--color-accent); background: var(--color-accent); + box-shadow: inset 0 0 0 4px var(--color-bg); +} +.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; } +.seg { + display: inline-flex; overflow: hidden; + border: 1px solid var(--color-divider); border-radius: var(--radius-md); +} +.seg-opt { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 12px; font-size: 13px; cursor: pointer; +} +.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); } +.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); } +.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } +.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; } + +/* — cards — */ +.card { + display: flex; flex-direction: column; gap: var(--space-2); + padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface); +} +.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); } +.card-title { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 17px; line-height: 1.2; +} +.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; } +.card-meta { + display: flex; align-items: center; gap: 6px; font-size: 11px; + color: color-mix(in srgb, var(--color-text) 50%, transparent); +} +.elev-sm { box-shadow: var(--shadow-sm); } +.elev-md { box-shadow: var(--shadow-md); } +.elev-lg { box-shadow: var(--shadow-lg); } + +/* — tags — */ +.tag { + display: inline-flex; align-items: center; font-size: 11px; + letter-spacing: 0.02em; padding: 3px 10px; + border-radius: calc(var(--radius-md) * 0.75); +} +.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); } +.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); } +.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); } +.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); } + +/* — navigation — */ +.nav { + display: flex; align-items: center; gap: var(--space-4); + padding: var(--space-3) var(--space-4); + border-bottom: none; +} +.nav-brand { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 18px; margin-right: auto; +} +.nav a { color: inherit; text-decoration: none; font-size: 14px; } +.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); } + +/* — tables — */ +.table { width: 100%; border-collapse: collapse; font-size: 14px; } +.table th { + text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; + color: color-mix(in srgb, var(--color-text) 60%, transparent); + padding: var(--space-2); border-bottom: 1px solid var(--color-divider); +} +.table td { + padding: var(--space-2); + border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); +} +.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); } + +/* — dialog — */ +.dialog-backdrop { + position: fixed; inset: 0; display: grid; place-items: center; + padding: var(--space-4); + background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent); +} +.dialog { + width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3); + padding: var(--space-4); border-radius: var(--radius-lg); + background: var(--color-surface); box-shadow: var(--shadow-lg); +} +.dialog-title { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 20px; +} +.dialog-body { font-size: 14px; opacity: 0.85; } +.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); } + +/* — blueprint frame: components are wireframe objects (see .blueprint + and .corner above) — square, transparent, hairline-bordered — */ +.card, .btn, .input, .tag, .seg, .dialog { border-radius: 0; } +.card, .dialog { background: transparent; border: 1px solid var(--color-divider); } +.btn { border: 1px solid var(--color-divider); } +.btn-primary { border-color: var(--color-accent); } +.btn-ghost { border-color: transparent; } diff --git a/前端UI八页面完成/_template/deck/palette.dc.html b/前端UI八页面完成/_template/deck/palette.dc.html new file mode 100644 index 0000000..56d21b2 --- /dev/null +++ b/前端UI八页面完成/_template/deck/palette.dc.html @@ -0,0 +1,1036 @@ + + + + + + + + + + + + + +Industry — deck + + + + + + + +
+ +
Design systems
+
+

Industry

+

A blueprint wireframe

+

Steel blue on a technical light ground, condensed headings, square corners and registration-marked frames.

+
+
Your name·July 2026
+
+ +
+ +

Contents

+
+
01Principles
+
02The ramps
+
03Imagery
+
04Roadmap
+
+ +
+ +
+ + +
+
01
+

Principles

+
+ +
+ +
+ +
01 · Principles
+

Working rules

+
    +
  • Everything on the board is a wireframe object — square, hairline-framed, marked.
  • +
  • Condensed headings label the drawing; Barlow carries the notes.
  • +
  • Steel blue is the one color, and the primary action is the one solid object.
  • +
+ +
+ +
+ +
01 · Principles
+

Two columns

+
+
+

Two bays of the board

+

Each column takes two modules, and the gutter between them is the drawing’s own — no rules are added, because the ruling is already there.

+
+
+

Seated, not floated

+

Heads seat their baselines on a group rule and the notes on the caption rule below, so both columns read line for line across the gutter.

+
+
+ +
+ +
+ +
01 · Principles
+

Three columns

+
+
+

One module each

+

Three columns take one module apiece and leave the fourth empty — on a drawn grid, an empty bay is a statement, not a gap.

+
+
+

A note’s measure

+

A module runs about twenty-six characters — notes and specifications, not essays. Keep each entry short.

+
+
+

Still seated

+

Heads on the group rule, notes on the caption rule — the narrow measure changes nothing about where type sits.

+
+
+ +
+ +
+ +
01 · Principles
+

Quadrants

+
+
+

Napkin sketch

+

Small, rough — where every drawing starts.

+
+
+

Schematic

+

Small, precise — every line means it.

+
+
+

Mock-up

+

Big, rough — shape first, dimensions later.

+
+
+

Production drawing

+

Big, precise — the one the shop builds from.

+
+
+ + +
+ +
+ + +
+
02
+

The ramps

+
+ +
+ +
+ +
02 · The ramps
+

Color roles

+ + + + + + + + + + +
RoleValueOn the groundUse
Text#1d1f2014.8:1Body copy and headings
Accent#5980a63.7:1Frames, marks and large text
Accent 700#4161805.8:1Accent-colored body copy
Surface#e9e9ea1.1:1Quiet fills in the wireframes
+ +
+ +
+ +
02 · The ramps
+

14.8:1

+

Body text against the technical light ground — the drawing is ink on paper, and the steel stays in the linework.

+ +
+ +
+ +
02 · The ramps
+

Contrast by ramp step

+
+ + + + + + + + + 3:1 interface minimum + 3 + 6 + 9 + 12 + 0 + + + + + + + + + + 1.0 + 1.1 + 1.3 + 1.8 + + 2.5 + 3.8 + 5.8 + 8.9 + 12.6 + body copy + 100 + 200 + 300 + 400 + 500 + 600 + 700 + 800 + 900 + + +
+

WCAG contrast of each accent-ramp step against the ground — the dashed line marks the 3:1 interface minimum.

+ +
+ +
+ +
02 · The ramps
+
+

Weekly sessions

+
+ Desktop + Mobile +
+
+
+ + + + + 10 + 20 + 0 + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + + +
+

Sample data, in thousands — the steel line is the one accent in the figure; the second series stays ink and dashes.

+ +
+ +
+ + +
+
03
+

Imagery

+
+ +
+ +
+ +
+
03 · Imagery
+

The duotone treatment

+

Every photograph is washed in the steel accent by the duotone wrapper — and an on-page figure is a wireframe object, hairline-framed with its registration marks.

+
+
+ + + +
+ +
+ +
+
+ +
+ +
+
03 · Imagery
+
+

Full bleed

+

One photograph, edge to edge — the scrim is the ground token rising from the foot, and the frame stays behind: a framed figure sits on the board, a bleed is the board.

+
+
+ +
+ +
+ +
+
03 · Imagery
+

Half bleed

+
    +
  • The photograph bleeds to three edges and holds half the stage
  • +
  • The copy keeps the content rhythm — same gridlines as every slide
  • +
  • Marks still hang left of the text, whatever sits behind them
  • +
+ +
+ +
+ +
+ +
+
03 · Imagery
+

Mirrored

+

The flip class changes the photograph’s side and the copy returns to the text axis — one pattern, both directions.

+ +
+ +
+ +
03 · Imagery
+
+ +
“Cards and figures stay transparent line drawings; the primary button is the one solid object on the board.”
+ +
— the Industry readme
+
+ +
+ +
+ + +
+
04
+

Roadmap

+
+ +
+ +
+ +
04 · Roadmap
+

The year, by milestone

+
+ + + + JAN + APR + JUL + OCT + JAN + + + + + + + Research + Prototype + Beta + Launch + Scale + scope and interviews + first working build + fifty design partners + general availability + second platform + +
+

Each milestone keeps one left edge — type and mark aligned — with the spacing opening gently downward from date to note, and the current step the one solid square.

+ +
+ +
+ +
Design systems
+
+

Thank you

+

Copy this folder for your next deck — the tokens and markup patterns carry the look.

+
+
you@example.com
+
+ +
+
+ + diff --git a/前端UI八页面完成/assets/photo.jpg b/前端UI八页面完成/assets/photo.jpg new file mode 100644 index 0000000..7bcc45b Binary files /dev/null and b/前端UI八页面完成/assets/photo.jpg differ diff --git a/前端UI八页面完成/deck-stage.js b/前端UI八页面完成/deck-stage.js new file mode 100644 index 0000000..c276e11 --- /dev/null +++ b/前端UI八页面完成/deck-stage.js @@ -0,0 +1,2952 @@ +/* BEGIN USAGE */ +/** + * — reusable web component for HTML decks. + * + * Handles: + * (a) speaker notes — reads + * + * The :not(:defined) rule prevents a flash of the first slide at its + * authored styles before this script runs and attaches the shadow root. + * + * Slides are the direct element children of . Each slide is + * automatically tagged with: + * - data-screen-label="NN Label" (1-indexed, for comment flow) + * - data-om-validate="no_overflowing_text,no_overlapping_text,slide_sized_text" + * + * Speaker notes stay in sync because the component posts {slideIndexChanged: N} + * to the parent — just include the #speaker-notes script tag if asked for notes. + * + * Authoring guidance: + * - Write slide bodies as static HTML inside , with sizing via + * CSS custom properties in a ' + + '
' + + ' ' + + '
' + icon + + '
' + + '
or browse files
' + + '
' + warnIcon + + '
This photo needs attribution
' + + '
' + + '
' + + '
' + + // Outside .frame, like .spill/.ctl — the frame's overflow:hidden + + // border-radius/clip-path would cut the credit off on circle/pill/mask. + // A SPAN, not an : the prescribed Unsplash credit holds two links + // (photographer + Unsplash), built per-render in _render(). + '' + + '
' + + ' ' + + '
' + + '
' + + '
' + + // data-dc-edit-transparent: the DC editor's edit-mode picker lets + // clicks through for chrome marked with it (EDIT_TRANSPARENT_SEL) + // — without it, Replace/Edit clicks in Edit mode are swallowed by + // element selection and the controls look dead. + '
' + + '
' + + ''; + this._frame = root.querySelector('.frame'); + this._ring = root.querySelector('.ring'); + this._img = root.querySelector('.frame img'); + this._empty = root.querySelector('.empty'); + this._cap = root.querySelector('.cap'); + this._sub = root.querySelector('.sub'); + this._spill = root.querySelector('.spill'); + this._ctl = root.querySelector('.ctl'); + this._credit = root.querySelector('.credit'); + this._attrError = root.querySelector('.attr-error'); + // Credit clicks open the link, not browse/reframe. + this._credit.addEventListener('click', (e) => e.stopPropagation()); + this._credit.addEventListener('dblclick', (e) => e.stopPropagation()); + this._ghost = root.querySelector('.ghost'); + this._err = null; + this._input = root.querySelector('input'); + this._depth = 0; + this._gen = 0; + // Encode-in-flight marker (the owning _ingest generation): while set, + // the same-src "nothing in flight" clear in _render must not fire — + // the stored value still points at the OLD image until the encode + // lands, so that clear would unmask the stale image mid-replace. + this._swapGen = 0; + // Render-owned swap in flight: set when _render assigns a new src, + // cleared only by the img's own load/error (or the empty branch). + // img.complete CANNOT stand in for this — setting src only QUEUES + // the current-request swap (a microtask), so synchronously after an + // assignment, complete still reports the OLD settled request. The + // pick path does exactly that: the host sets src, credit, and + // credit-href back-to-back in one task, and renders #2/#3 would + // read the stale complete === true and drop the mask one render + // after it was set. + this._loadPending = false; + // See _render's empty branch: a transient attribution-error wipe of a + // showing image must make the follow-up render a replacement (spinner), + // not a first fill (blank frame). + this._hidShowing = false; + this._view = { s: 1, x: 0, y: 0 }; + this._subFn = () => this._render(); + // Shadow-DOM listeners live with the shadow DOM — bound once here so + // disconnect/reconnect (e.g. React remount) doesn't stack handlers. + this._empty.addEventListener('click', () => this._input.click()); + root.addEventListener('click', (e) => { + const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + // The hidden controls are opacity-0 but still tabbable — without + // this gate a keyboard user could drive them on a read-only share + // link (mirrors the dblclick handler's editable gate). + if (!this.hasAttribute('data-editable')) return; + if (act === 'replace') { + this._exitReframe(true); + // Host-owned picker (Unsplash modal; it also offers local import). + this.dispatchEvent(new CustomEvent('image-slot:pick', { + bubbles: true, composed: true, detail: { id: this.id || null } + })); + } + if (act === 'edit') { + if (!this._reframes()) return; + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + } + }); + this._input.addEventListener('change', () => { + const f = this._input.files && this._input.files[0]; + if (f) this._ingest(f); + this._input.value = ''; + }); + // naturalWidth/Height aren't known until load — re-apply so the cover + // baseline is computed from real dimensions, not the 100%×100% fallback. + // load/error also release the replacement-in-flight mask (via the + // single discipline in _releaseMask): the swap is only revealed once + // the new image can actually paint (on error the frame shows its + // background, same as a fresh slot with a broken src). + this._img.addEventListener('load', () => { + this._loadPending = false; + this._releaseMask(true); + this._applyView(); + }); + this._img.addEventListener('error', () => { + this._loadPending = false; + this._releaseMask(true); + }); + // Gated only on editable — any filled slot can be repositioned/scaled, + // regardless of fit. Share links (no writeFile) stay static. + this.addEventListener('dblclick', (e) => { + if (!this.hasAttribute('data-editable') || !this._reframes()) return; + e.preventDefault(); + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + }); + // Pan + resize both originate on the spill layer. A handle pointerdown + // drives an aspect-locked resize anchored at the opposite corner; any + // other pointerdown on the spill pans. Offsets are frame-% so a + // reframed slot survives responsive resize / PPTX export. + this._spill.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; + e.preventDefault(); + e.stopPropagation(); + this._spill.setPointerCapture(e.pointerId); + const rect = this.getBoundingClientRect(); + const fw = rect.width || 1, fh = rect.height || 1; + const corner = e.target.getAttribute && e.target.getAttribute('data-c'); + let move; + if (corner) { + // Resize about the OPPOSITE corner. Viewport-px throughout (rect + // fw/fh, not clientWidth) so the math survives a transform:scale() + // ancestor — deck_stage renders slides scaled-to-fit. + const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); + const sx = corner.includes('e') ? 1 : -1; + const sy = corner.includes('s') ? 1 : -1; + const s0 = this._view.s; + const w0 = iw * base * s0, h0 = ih * base * s0; + const cx0 = (50 + this._view.x) / 100 * fw; + const cy0 = (50 + this._view.y) / 100 * fh; + const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2; + const diag0 = Math.hypot(w0, h0); + const ux = sx * w0 / diag0, uy = sy * h0 / diag0; + move = (ev) => { + const proj = (ev.clientX - rect.left - ox) * ux + + (ev.clientY - rect.top - oy) * uy; + const s = clampS(s0 * proj / diag0); + const d = diag0 * s / s0; + this._view.s = s; + this._view.x = (ox + ux * d / 2) / fw * 100 - 50; + this._view.y = (oy + uy * d / 2) / fh * 100 - 50; + this._clampView(); + this._applyView(); + }; + } else { + this.setAttribute('data-panning', ''); + const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y }; + move = (ev) => { + this._view.x = start.x + (ev.clientX - start.px) / fw * 100; + this._view.y = start.y + (ev.clientY - start.py) / fh * 100; + this._clampView(); + this._applyView(); + }; + } + const up = () => { + try { this._spill.releasePointerCapture(e.pointerId); } catch {} + this._spill.removeEventListener('pointermove', move); + this._spill.removeEventListener('pointerup', up); + this._spill.removeEventListener('pointercancel', up); + this.removeAttribute('data-panning'); + this._dragUp = null; + }; + // Stashed so _exitReframe (Escape / outside-click mid-drag) can + // tear the capture + listeners down synchronously. + this._dragUp = up; + this._spill.addEventListener('pointermove', move); + this._spill.addEventListener('pointerup', up); + this._spill.addEventListener('pointercancel', up); + }); + // Wheel zoom stays available inside reframe mode as a trackpad nicety — + // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). + this.addEventListener('wheel', (e) => { + if (!this.hasAttribute('data-reframe')) return; + e.preventDefault(); + const r = this.getBoundingClientRect(); + const cx = (e.clientX - r.left) / r.width * 100 - 50; + const cy = (e.clientY - r.top) / r.height * 100 - 50; + const prev = this._view.s; + const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); + if (next === prev) return; + const k = next / prev; + this._view.s = next; + this._view.x = cx * (1 - k) + this._view.x * k; + this._view.y = cy * (1 - k) + this._view.y * k; + this._clampView(); + this._applyView(); + }, { passive: false }); + } + + connectedCallback() { + // Warn once per page — an id-less slot works for the session but + // cannot persist, and two id-less slots would share nothing. + if (!this.id && !ImageSlot._warned) { + ImageSlot._warned = true; + console.warn(' without an id will not persist its dropped image.'); + } + this.addEventListener('dragenter', this); + this.addEventListener('dragover', this); + this.addEventListener('dragleave', this); + this.addEventListener('drop', this); + subs.add(this._subFn); + // The host may inject window.omelette.writeFile AFTER the first render; + // re-render on hover so the editable-gated controls reliably appear. + this.addEventListener('pointerenter', this._subFn); + // width%/height% in _applyView encode the frame aspect at call time — + // a host resize (responsive grid, pane divider) would stretch the + // image until the next _render. Re-render on size change: _render() + // re-seeds _view from stored before clamp/apply, so a shrink→grow + // cycle round-trips instead of ratcheting x/y toward the narrower + // frame's clamp range. + this._ro = new ResizeObserver(() => this._render()); + this._ro.observe(this); + load(); + this._render(); + } + + disconnectedCallback() { + subs.delete(this._subFn); + this.removeEventListener('pointerenter', this._subFn); + this.removeEventListener('dragenter', this); + this.removeEventListener('dragover', this); + this.removeEventListener('dragleave', this); + this.removeEventListener('drop', this); + if (this._ro) { this._ro.disconnect(); this._ro = null; } + // commit=false: a disconnect is not a user intent — committing here + // would persist whatever half-finished drag a React remount or DOM + // splice happened to interrupt. Deliberate exits commit on their own + // paths (Escape/click-out/toggle), and unloads commit via pagehide. + this._exitReframe(false); + } + + _enterReframe() { + if (this.hasAttribute('data-reframe')) return; + this.setAttribute('data-reframe', ''); + this._signalReframe(true); + // Best-effort commit when the document unloads mid-reframe (a host + // navigation racing the enter signal, a manual reload, tab close): + // the sidecar write rides the host bridge, which outlives this + // document, so the crop survives even though the mode dies with the + // DOM. Held on the instance so _exitReframe detaches exactly what + // was attached. + this._pagehide = () => { this._exitReframe(true); flushNow(); }; + window.addEventListener('pagehide', this._pagehide); + // Promote spill to the top layer, then keep it pinned over the frame: + // scroll/resize cover the common cases, and a per-frame rect check + // catches layout shifts that fire neither (an image above finishing + // load, streamed DOM pushing the slot down, an ancestor transform + // change) so the overlay can't detach from the frame. + try { this._spill.showPopover(); } catch {} + // After the spill, so the controls stack above it in the top layer. + try { this._ctl.showPopover(); } catch {} + this._reposition = () => { if (this.hasAttribute('data-reframe')) this._applyView(); }; + window.addEventListener('scroll', this._reposition, true); + window.addEventListener('resize', this._reposition); + this._lastRect = ''; + this._watch = () => { + if (!this.hasAttribute('data-reframe')) return; + const r = this.getBoundingClientRect(); + const key = r.left + ',' + r.top + ',' + r.width + ',' + r.height; + if (key !== this._lastRect) { this._lastRect = key; this._applyView(); } + this._watchId = requestAnimationFrame(this._watch); + }; + this._watchId = requestAnimationFrame(this._watch); + this._applyView(); + // Close on click outside (the spill handler stopPropagation()s so + // in-image drags don't reach this) and on Escape. Listeners are held + // on the instance so _exitReframe / disconnectedCallback can detach + // exactly what was attached. + this._outside = (e) => { + if (e.composedPath && e.composedPath().includes(this)) return; + this._exitReframe(true); + }; + this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); }; + document.addEventListener('pointerdown', this._outside, true); + document.addEventListener('keydown', this._esc, true); + } + + _exitReframe(commit) { + if (!this.hasAttribute('data-reframe')) return; + if (this._dragUp) this._dragUp(); + this.removeAttribute('data-reframe'); + this.removeAttribute('data-panning'); + if (this._outside) document.removeEventListener('pointerdown', this._outside, true); + if (this._esc) document.removeEventListener('keydown', this._esc, true); + this._outside = this._esc = null; + if (this._reposition) { + window.removeEventListener('scroll', this._reposition, true); + window.removeEventListener('resize', this._reposition); + this._reposition = null; + } + if (this._watchId) { cancelAnimationFrame(this._watchId); this._watchId = 0; } + if (this._pagehide) { + window.removeEventListener('pagehide', this._pagehide); + this._pagehide = null; + } + try { this._spill.hidePopover(); } catch {} + try { this._ctl.hidePopover(); } catch {} + this._ctl.style.left = ''; this._ctl.style.top = ''; + if (commit) this._commitView(); + this._signalReframe(false); + } + + // Reframe state lives only in this DOM until commit, invisible to the + // host's dirty signals — announce enter/exit so the host can hold + // auto-reloads for exactly the gesture (the guest bundle forwards + // image-slot:reframe to the host as imageSlotReframe). Dispatched on + // the element (composed, so it escapes shadow roots) while connected; + // a disconnected exit (disconnectedCallback) falls back to document so + // the host still hears it. + _signalReframe(active) { + const target = this.isConnected ? this : document; + target.dispatchEvent(new CustomEvent('image-slot:reframe', { + bubbles: true, composed: true, + detail: { active: active, id: this.id || null } + })); + } + + // Public: host's "Import from computer" calls this to run local browse. + openFilePicker() { this._exitReframe(true); this._input.click(); } + + // A src write is a newer intent for this slot's content — the host + // pick path (setImageSlotImage) or an agent edit — so it must win + // over any encode still in flight from an earlier drop: left live, + // that encode lands later, passes _ingest's gen guard, and its + // setSlot silently overwrites the pick (the stored value shadows + // src in _render). Bumping _gen kills the encode before its own + // _swapGen clear runs, so clear the dead claim here too — otherwise + // _releaseMask (gated on !_swapGen) never fires and the pick's + // spinner is stranded. src ONLY: the pick sets credit/credit-href + // in the same task, and clearing _swapGen on those would let the + // same-src branch unmask the old image mid-encode. + attributeChangedCallback(name, oldVal, newVal) { + if (name === 'src' && oldVal !== newVal) { + this._gen++; + this._swapGen = 0; + } + if (this.shadowRoot) this._render(); + } + + // handleEvent — one listener object for all four drag events keeps the + // add/remove symmetric and the depth counter correct. + handleEvent(e) { + if (e.type === 'dragenter' || e.type === 'dragover') { + // Without preventDefault the browser never fires 'drop'. + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + if (e.type === 'dragenter') this._depth++; + this.setAttribute('data-over', ''); + } else if (e.type === 'dragleave') { + // dragenter/leave fire for every descendant crossing — count depth + // so hovering the icon inside the empty state doesn't flicker. + if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); } + } else if (e.type === 'drop') { + e.preventDefault(); + e.stopPropagation(); + this._depth = 0; + this.removeAttribute('data-over'); + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) this._ingest(f); + } + } + + async _ingest(file) { + this._setError(null); + if (!file || ACCEPT.indexOf(file.type) < 0) { + this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); + return; + } + // toDataUrl can take hundreds of ms on a large photo. A Clear or a + // newer drop during that window would be clobbered when this await + // resumes — bump + capture a generation so stale encodes bail. + const gen = ++this._gen; + // Replacing a shown image: surface the swap through the encode too, + // not just the decode — otherwise the old photo sits there with no + // feedback while the canvas re-encode runs. An empty slot keeps its + // placeholder (no spinner) until the encode lands, as before. + // _swapGen guards the mask against re-renders DURING the encode + // (pointerenter, ResizeObserver, another slot's store write): the + // stored value still resolves to the old image there, so _render's + // same-src clear would otherwise unmask it mid-replace. + if (this.hasAttribute('data-filled')) { + this.setAttribute('data-swapping', ''); + this._swapGen = gen; + } + try { + const w = this.clientWidth || this.offsetWidth || MAX_DIM; + const url = await toDataUrl(file, w); + if (gen !== this._gen) return; + // Only exit reframe once the new image is in hand — a rejected type + // or decode failure leaves the in-progress crop untouched. + this._exitReframe(false); + // Clear BEFORE setSlot: its synchronous re-render must see no + // pending encode, so a byte-identical re-upload (same data URL, no + // load event coming) still clears the mask via the complete branch. + this._swapGen = 0; + const val = { u: url, s: 1, x: 0, y: 0 }; + setSlot(this.id || '', val); + // Keep a session-local copy for id-less slots so the drop still + // shows, even though it cannot persist. + if (!this.id) { this._local = val; this._render(); } + } catch (err) { + if (gen !== this._gen) return; + this._swapGen = 0; + // Reveal the kept old image — unless another replacement (a + // remote pick's src swap) is still in flight, in which case the + // mask stays until THAT image settles (its load/error releases). + this._releaseMask(); + this._setError('Could not read that image.'); + console.warn(' ingest failed:', err); + } + } + + _setError(msg) { + if (this._err) { this._err.remove(); this._err = null; } + if (!msg) return; + const d = document.createElement('div'); + d.className = 'err'; d.textContent = msg; + this.shadowRoot.appendChild(d); + this._err = d; + setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000); + } + + // Reframing (pan/resize) is available on any filled slot — the user can + // always reposition/scale. `fit` only sets the initial baseline (see + // _geom): contain starts fully-visible, cover starts frame-filling. + _reframes() { + return this.hasAttribute('data-filled'); + } + + // The single release discipline for the replacement-in-flight mask + // (data-swapping). The mask comes off only when BOTH hold: + // - no encode is pending (_swapGen) — mid-encode the stored value + // still resolves to the old image, so any reveal paints it; + // - the frame img has settled on its current src — an unsettled src + // means some replacement is still in flight (e.g. a remote pick), + // whoever started it, and revealing would paint the previous + // frame. The load/error listeners pass settled=true (the event IS + // the settlement signal, per spec complete is true by then); + // other callers rely on the complete flag (covers loaded AND + // failed). + // Every release path funnels through here EXCEPT _render's empty + // branch (the img is being cleared — nothing will ever settle). + _releaseMask(settled) { + if ( + !this._swapGen && + !this._loadPending && + (settled || this._img.complete) + ) { + this.removeAttribute('data-swapping'); + } + } + + // Baseline geometry, shared by clamp/apply/resize. `base` is the scale at + // view-scale s=1: cover = fill the frame (overflow on the looser axis), + // contain = fit fully inside (letterboxed). Zooming a contain image past + // s where it overflows naturally becomes a crop. Null until the img has + // loaded (naturalWidth is 0 before that) or when the slot has no layout + // box — ResizeObserver fires with a 0×0 rect under display:none, and + // clamping against a degenerate 1×1 frame would silently pull the stored + // pan toward zero. + _geom() { + const iw = this._img.naturalWidth, ih = this._img.naturalHeight; + const fw = this.clientWidth, fh = this.clientHeight; + if (!iw || !ih || !fw || !fh) return null; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain + ? Math.min(fw / iw, fh / ih) + : Math.max(fw / iw, fh / ih); + return { iw, ih, fw, fh, base }; + } + + _clampView() { + // Pan range on each axis is half the overflow past the frame edge. + const g = this._geom(); + if (!g) return; + const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); + const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); + this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); + this._view.y = Math.max(-my, Math.min(my, this._view.y)); + } + + _applyView() { + const g = this._geom(); + // Top-layer controls: pin to the frame's top-right in viewport px + // (the same 8px inset as the in-frame layout; unscaled — top-layer UI + // reads as chrome, not page content). BEFORE the geometry branch: + // placement needs only the frame rect, and a not-yet-loaded or broken + // src must not leave the promoted strip floating unpositioned. Gated + // on the popover actually being open: without the Popover API, + // showPopover() threw (swallowed in _enterReframe), .ctl stays in + // its in-frame absolute layout, and viewport-px coordinates would + // shove it off-frame — and matches(':popover-open') itself throws + // there (unknown pseudo-class), hence the try/catch. + if (this.hasAttribute('data-reframe')) { + let onTop = false; + try { onTop = this._ctl.matches(':popover-open'); } catch {} + if (onTop) { + const r = this.getBoundingClientRect(); + this._ctl.style.left = (r.right - 8) + 'px'; + this._ctl.style.top = (r.top + 8) + 'px'; + } + } + if (!g) { + // Dimensions not known yet (before img load) — centered fit so there + // is no flash of an unpositioned image before the geometry lands. + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + this._img.style.width = '100%'; + this._img.style.height = '100%'; + this._img.style.left = '50%'; + this._img.style.top = '50%'; + this._img.style.objectFit = contain ? 'contain' : 'cover'; + return; + } + // Baseline (cover-fill or contain-fit) × view scale. Width/height and + // left/top are all frame-% — depends only on the frame aspect ratio, so + // a responsive resize keeps the same crop. The spill layer mirrors the + // same box so its corners = image corners. + const k = g.base * this._view.s; + const w = (g.iw * k / g.fw * 100) + '%'; + const h = (g.ih * k / g.fh * 100) + '%'; + const l = (50 + this._view.x) + '%'; + const t = (50 + this._view.y) + '%'; + this._img.style.width = w; this._img.style.height = h; + this._img.style.left = l; this._img.style.top = t; + this._img.style.objectFit = ''; + if (this.hasAttribute('data-reframe')) { + // Top-layer spill: position in viewport px over the frame. The top + // layer escapes ancestor transforms entirely, so EVERY term must be + // in viewport units: getBoundingClientRect gives the frame's scaled + // origin AND size, and the rect/layout ratio rescales the ghost — + // sizing from layout px alone renders it 1/scale too large under a + // scaled deck slide. Inner ghost + handles stay box-relative. + const r = this.getBoundingClientRect(); + const sx = g.fw ? r.width / g.fw : 1; + const sy = g.fh ? r.height / g.fh : 1; + this._spill.style.width = (g.iw * k * sx) + 'px'; + this._spill.style.height = (g.ih * k * sy) + 'px'; + this._spill.style.left = (r.left + (50 + this._view.x) / 100 * r.width) + 'px'; + this._spill.style.top = (r.top + (50 + this._view.y) / 100 * r.height) + 'px'; + } + } + + _commitView() { + const v = { s: this._view.s, x: this._view.x, y: this._view.y }; + if (this._userUrl) v.u = this._userUrl; + // Framing-only (no u) persists too so an author-src slot remembers its + // crop; clearing the sidecar still falls through to src=. + if (this.id) setSlot(this.id, v); + else { this._local = v; } + } + + _render() { + // Shape / mask. Presets use border-radius so the dashed ring can + // follow the rounded outline; clip-path is only applied for an + // explicit `mask` (the ring is hidden there since a rectangle + // dashed border chopped by an arbitrary polygon looks broken). + const mask = this.getAttribute('mask'); + const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); + let radius = ''; + if (shape === 'circle') radius = '50%'; + else if (shape === 'pill') radius = '9999px'; + else if (shape === 'rounded') { + const n = parseFloat(this.getAttribute('radius')); + radius = (Number.isFinite(n) ? n : 12) + 'px'; + } + this._frame.style.borderRadius = mask ? '' : radius; + this._frame.style.clipPath = mask || ''; + this._ring.style.borderRadius = mask ? '' : radius; + this._ring.style.display = mask ? 'none' : ''; + + // Controls and reframe entry gate on this so share links stay read-only. + const editable = !!(window.omelette && window.omelette.writeFile); + this.toggleAttribute('data-editable', editable); + this._sub.style.display = editable ? '' : 'none'; + + // Content. The sidecar is also writable by the agent's write_file + // tool, so its value isn't guaranteed canvas-originated — only accept + // data:image/ URLs from it. The `src` attribute is author-controlled + // (Claude wrote it into the HTML) so it passes through unchanged. + let stored = this.id ? getSlot(this.id) : this._local; + if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null; + const srcAttr = this.getAttribute('src') || ''; + this._userUrl = (stored && stored.u) || null; + const url = this._userUrl || srcAttr; + // Don't clobber an in-flight reframe with a store-triggered re-render. + if (!this.hasAttribute('data-reframe')) { + this._view = { + s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, + x: stored && Number.isFinite(stored.x) ? stored.x : 0, + y: stored && Number.isFinite(stored.y) ? stored.y : 0, + }; + } + this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; + // Toggle via style.display — the [hidden] attribute alone loses to + // the display:flex / display:block rules in the stylesheet above. + // An Unsplash src with no credit attribute must NOT render — showing + // the photo uncredited is the Unsplash-terms violation itself. The + // error tile replaces the photo until the credit is written. A + // user-dropped image is the user's own content and always renders. + // Trimmed: credit is agent/user-editable content, and a whitespace- + // only value must count as missing — otherwise it would suppress the + // error tile AND render an empty credit box (no text, no links), + // exactly the unattributed state this gate exists to prevent. + const credit = (this.getAttribute('credit') || '').trim(); + const attrError = !!( + !credit && !this._userUrl && srcAttr && isUnsplashHost(srcAttr) + ); + this.toggleAttribute('data-attribution-error', attrError); + if (url && !attrError) { + const prev = this._img.getAttribute('src'); + if (prev !== url) { + // Replacing an already-shown image: mark the swap BEFORE setting + // src so the stale frame is never revealed (see the data-swapping + // stylesheet rules). First fill (prev empty) keeps the existing + // placeholder-until-load behavior — no spinner. _hidShowing + // covers the pick path's transient attribution-error wipe: prev + // is gone, but an image WAS showing, so this is a replacement. + if (prev || this._hidShowing) this.setAttribute('data-swapping', ''); + // Mark the swap BEFORE assigning src: complete keeps reporting + // the old settled request until the browser's + // update-the-image-data microtask runs, so same-task re-renders + // (the pick path's credit/credit-href setAttributes) need this + // flag, not complete, to know a load is in flight. + this._loadPending = true; + this._img.src = url; + this._ghost.src = url; + } else { + // Same-src re-render — release if settled, so an ingest-set + // spinner can't stick after a byte-identical re-upload (same + // data URL, no further load event ever fires). + this._releaseMask(); + } + this._hidShowing = false; + this._img.style.display = 'block'; + this._empty.style.display = 'none'; + this.setAttribute('data-filled', ''); + this._clampView(); + this._applyView(); + } else { + this.removeAttribute('data-swapping'); + // The src is being removed — no load/error will ever fire for it. + this._loadPending = false; + // A transient attribution-error wipe of a showing image happens on + // the pick path: the host sets src one setAttribute before credit, + // so render N hides the old image (attrError) and render N+1 + // restores a URL. Remember the wipe so that restore renders as a + // replacement (spinner), not a first fill (blank frame). + this._hidShowing = attrError && !!this._img.getAttribute('src'); + this._img.style.display = 'none'; + this._img.removeAttribute('src'); + this._ghost.removeAttribute('src'); + // The error tile owns the blocked-photo state; .empty stays for + // the genuinely-empty slot. + this._empty.style.display = attrError ? 'none' : 'flex'; + this.removeAttribute('data-filled'); + } + + // Credit belongs to the author src, so a user drop hides it. + // textContent + the http(s)-only funnel keep external strings inert. + const showCredit = !!(url && credit && !this._userUrl && !attrError); + this._credit.textContent = ''; + if (showCredit) { + // Validate once (resolved against the document, http(s) only), + // then append the terms-required utm referral params to links + // that point back at unsplash.com. + let href = ''; + const rawHref = this.getAttribute('credit-href') || ''; + if (rawHref) { + try { + const u = new URL(rawHref, document.baseURI); + if (u.protocol === 'http:' || u.protocol === 'https:') { + href = withReferral(u.href); + } + } catch {} + } + const mkLink = (text, linkHref) => { + const a = document.createElement('a'); + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + a.setAttribute('href', linkHref); + a.textContent = text; + return a; + }; + // Unsplash's prescribed credit is TWO links — the photographer's + // name to their profile (credit-href) and 'Unsplash' to the + // homepage. Render that split whenever the text has the canonical + // shape; other text keeps the legacy single-link rendering. + const m = /^Photo by (.+) on Unsplash$/.exec(credit); + if (m) { + this._credit.appendChild(document.createTextNode('Photo by ')); + this._credit.appendChild( + href ? mkLink(m[1], href) : document.createTextNode(m[1]) + ); + this._credit.appendChild(document.createTextNode(' on ')); + this._credit.appendChild(mkLink('Unsplash', UNSPLASH_HOMEPAGE_HREF)); + } else if (href) { + this._credit.appendChild(mkLink(credit, href)); + } else { + this._credit.textContent = credit; + } + } + this.toggleAttribute('data-credit', showCredit); + } + } + + if (!customElements.get('image-slot')) { + customElements.define('image-slot', ImageSlot); + } +})(); diff --git a/前端UI八页面完成/support.js b/前端UI八页面完成/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/前端UI八页面完成/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: