---
title: "線上更新循序圖 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 技術規格 · 2026-11-01"
h1: "循序圖"
subtitle: "檢查到就緒、按更新到回報、Agent 跟進、發版：誰打哪支端點、跑哪支腳本、要多久、失敗怎樣"
lede: "四張圖把線上更新從頭走到尾：**①** 主系統定時問更新站、通知管理員、下載、驗章、就緒；**②** 管理員按更新、主系統寫更新單、主機 systemd 收單、八步接力、換接、前門恢復、回報更新站；**③** 主系統升完推 Agent、Agent 下載、Agent 主機跑升級、結果隨心跳回來；**④** CI 出包登記草稿、傳 R2、決策者在自己電腦蓋章發布、客戶端看到。每張圖後面一張逐步表，時間與端點都對到程式碼。"
chips: [
  {text: "主系統只寫單、主機只寫檔（單向）", kind: accent},
  {text: "更新站沒有私鑰", kind: accent},
  {text: "陷阱：CI 正式發布路徑還不帶維運指令小檔", kind: warn}
]
footer: "FR-167 · 循序圖 · 2026-11-01 · 設計脈絡見 [design.md](design.md) · 沿革見 LOG 與 git log"
---

## 參與者 {#actors nav="參與者"}

| 代號 | 是什麼 | 在哪 |
|---|---|---|
| 管理員 | 有「系統更新」權限的人，在更新中心頁操作 | 瀏覽器 |
| FE | 更新中心頁（compliance-manager-fe `src/views/product-update/`） | 瀏覽器 |
| BE | 主系統後端 api（`/api/1.0/system/product-update/*`） | 客戶主機 `guidant-api` 容器 |
| 排程 | 主系統背景排程（`core/scheduler_update.py`） | 同上，gunicorn master 只跑一份 |
| Worker | 主系統背景工作（`RUN_MODE=worker`），跑下載 | `guidant-worker` 容器 |
| 更新站 | Cloudflare Worker＋D1＋R2（update-center repo） | 原廠 |
| R2 | 放出貨包的物件儲存，下載用 15 分鐘預簽網址 | 原廠 |
| 主機 | 客戶主機上的 systemd 與 `guidantai` | 客戶主機 root |
| 前門 | `guidant-fe` 容器（nginx），後端停時送維護頁 | 客戶主機 |
| Agent | 證據蒐集 Agent（evidence-agent） | Agent 主機容器 |
| Agent 主機 | Agent 主機上的 systemd 與 Agent 安裝程式 | Agent 主機 root |
| CI | GitLab CI `package` 階段（188 build 機） | 原廠 |
| CLI | `guidant-release`（update-center `cli/`），握私鑰 | 決策者電腦 |

主系統與主機的交接只有檔案：主系統寫 `upgrade/orders/`、`upgrade/incoming/`；主機寫 `upgrade/upgrade-progress.json`、`upgrade/draining`、`upgrade/last-switch.json`。主機不打主系統。

## 圖 1 每日檢查 → 通知 → 下載 → 驗證 → 就緒 {#check nav="檢查到就緒"}

```{.mermaid cap="圖 1 — 發現新版到包就緒；下載一定要管理員按，排程只問不下"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    autonumber
    participant SCH as 排程
    participant BE as BE
    participant US as 更新站
    participant ADM as 管理員
    participant FE as FE
    participant WK as Worker
    participant R2 as R2
    participant H as 主機
    SCH->>BE: 每 6 小時（與啟動時一次）run_scheduled_check
    BE->>US: POST /v1/check（授權檔、指紋、現行版）
    US-->>BE: none，或 available＋path＋每段 signed_manifest
    BE->>BE: 每段用本機印樣驗章、版號遞增、主版號、min_base_version
    BE->>BE: 寫 last_check（系統設定）
    BE-)ADM: 新的一版才寄信（同一版不重寄）
    ADM->>FE: 開更新中心
    FE->>BE: GET /system/product-update/status
    ADM->>FE: 按「下載」或「更新到 X」
    FE->>BE: POST /download 或 POST /apply
    BE->>BE: 建紀錄 downloading，入列背景工作
    WK->>BE: 撿單 product_update_download
    loop 路徑每一段
        WK->>US: POST /v1/download-url（version）
        US-->>WK: 15 分鐘預簽網址＋sha256＋size
        WK->>R2: GET（Range 續傳）寫 incoming/*.tar.gz.part
        WK->>WK: 大小與 sha256＝清單才改名
        WK->>US: POST /v1/download-url（artifact=ops），拿得到才下
    end
    WK->>BE: 紀錄改 ready、記下載速率
    alt 按的是「更新到 X」
        WK->>H: 直接寫更新單（接圖 2）
    else 只按了下載
        WK->>H: 寫 preflight 單到 orders/
        H->>H: 只跑第 1 步，寫進度檔 status=preflight
    end
    FE->>BE: 每 3 秒 GET /status，看到 ready 與預檢結果
```

| 步 | 誰 | 打哪支端點／跑哪支程式 | 要多久 | 失敗怎樣 |
|---|---|---|---|---|
| 1 | 排程 | `core/scheduler_update.py:13-36`，`CHECK_INTERVAL_HOURS=6`；啟動時另跑一次；關了自動檢查或沒設更新站網址就不問 | — | 例外只記 log |
| 2 | BE | `update_site_client.check` → `POST /v1/check` | 連線 10 秒、讀 30 秒（`update_site_client.py:20-21`） | 不拋：`last_check` 記 `ok=false`、`error_code`（站沒設定／連不到），畫面顯示「最近一次檢查失敗」 |
| 3 | 更新站 | `src/routes/client.ts:41-45`：驗授權章、指紋綁定、拒絕名單、主版號，回比現行新的版本；草稿只回給內部先裝名單 | 秒級 | 401 `invalid_license`、403 `license_denied` |
| 4 | BE | `product_update_flow._parse_available`（`:217-231`）→ `package_verifier.verify_manifest`（`:206-218`） | 毫秒 | 任一段驗不過：整段不認，`error_code=PRODUCT_UPDATE_BAD_SIGNATURE` |
| 5 | BE | `_write_last_check`（ROOT 租戶 `system_configs`，group `PRODUCT_UPDATE`、key `last_check`） | — | 寫不進只記 log |
| 6 | BE | `_notify_admins`（`:344-366`），收件人同授權到期通知；`notified_version` 去重 | 每封秒級 | 一封寄不出不影響其他人；一封都沒寄出就不記去重，下次再寄 |
| 7～8 | FE | `GET /system/product-update/status`（`product_update_app_service.py:130-150`） | — | — |
| 9～10 | FE→BE | `POST /system/product-update/download`；或 `POST /system/product-update/apply`（包還沒下時先建下載單並記下誰按、排程時間、Agent 選擇） | — | 已有 `downloading`／`ready`：409 `PRODUCT_UPDATE_BUSY`；排程時間早於預估下載完成：擋下（`upgrade_order.py:320-330`） |
| 11 | BE | `start_download`（`product_update_flow.py:406-419`）：紀錄 `downloading`，入列 `product_update_download`，`max_attempts=1` | — | — |
| 12 | Worker | `run_download`（`:474-513`），以按下載的人的身分跑 | — | — |
| 13～14 | Worker→更新站 | `POST /v1/download-url`；R2 預簽 15 分鐘（`update-center src/releases.ts:10`） | 秒級 | 503 `download_unavailable` → 紀錄 `failed` |
| 15 | Worker→R2 | `package_downloader.download_path`（`:242-262`）：一開始就以整條路徑剩餘大小檢查空間；每收 64MB 看一次是否已取消 | 依頻寬（連線 15 秒、讀 120 秒逾時，`package_downloader.py:32-34`） | 中斷留 `.part` 下次續傳；取消時整條路徑的檔一起刪 |
| 16 | Worker | 大小與 sha256 對得上清單才 `os.replace` 成正式檔名 | 5GB 約 20 秒算雜湊 | 對不上刪檔，紀錄 `failed`（包已刪、要重新下載） |
| 17 | Worker | 維運指令小檔 `guidant-ai-<版>.ops.tar.gz`（清單有 `ops_sha256` 才下） | 秒級 | 拿不到不算失敗；主機第 0 步改從整包抽 |
| 18 | Worker | `_finish_job(ready)`、`_record_download_rate` | — | — |
| 19 | Worker | `apply_after_download`（`upgrade_order.py:342-365`）：紀錄有 `applied_by` 才接著寫單 | 跳過重算雜湊 | 下載完已過排定時間：留 `ready` 記原因；寫單失敗：留 `ready` 記原因 |
| 20～21 | Worker→主機 | `request_preflight`（`upgrade_order.py:388-399`）寫 `type=preflight` 單；主機 `ord_run_preflight`（`upgrade_order.sh:240-263`） | 第 1 步 120 秒上限 | 寫單失敗只記 log；主機正在升級（拿不到鎖）就略過 |
| 22 | FE | 輪詢規則 `pollIntervalMs`（FE `productUpdateState.js:30-35`）：有還沒結束的單就 3 秒一次 | — | — |

**離線**：主機連不到更新站時，原廠提供離線包，管理員在更新中心分塊上傳（`POST /uploads` → `PUT /uploads/<id>/chunks/<n>` → `POST /uploads/<id>/complete`），驗章、比大小與 sha256 後一樣建 `ready` 紀錄並送 preflight 單（`product_update_flow.py:576-640`）。驗不過建一列 `failed`。

## 圖 2 按更新 → 寫單 → systemd 收單 → 八步接力 → 換接 → 前門 → 回報 {#apply nav="按更新到回報"}

```{.mermaid cap="圖 2 — 立即更新：主系統只寫單，主機只寫檔；結果靠每分鐘讀進度檔"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    autonumber
    participant ADM as 管理員
    participant FE as FE
    participant BE as BE
    participant SD as 主機 systemd
    participant RN as 主機 runner
    participant FD as 前門
    participant SCH as 排程
    participant US as 更新站
    ADM->>FE: 按「立即更新」（勾或不勾 Agent 跟進）
    FE->>BE: POST /system/product-update/apply
    BE->>BE: 每段重驗包、紀錄 scheduled、記 job_id
    BE->>SD: 寫 orders/{job_id}.json（暫存再 rename）
    SD->>SD: path 單元觸發 upgrade --from-order
    SD->>SD: 讀單、每段重驗包
    SD->>RN: 交給 upgrade_runner.sh incoming 的包
    RN->>RN: 第 0 步 驗包、換成新版維運指令、exec 新版
    RN->>RN: 第 1 步 預檢
    RN->>RN: 第 2 步 寫 draining，等工作做完
    RN->>RN: 第 3 步 停服務、複製資料庫到 pgdata-next
    Note over FD: 後端停了，前門送維護頁、讀進度檔
    RN->>RN: 第 4 步 載映像、以複本起 db、migrate
    RN->>RN: 第 5 步 以複本起受檢 api、實打
    RN->>RN: 第 6 步 換接、起新版、寫 succeeded、寫 last-switch.json
    RN->>FD: 最後重建前門
    FD-->>ADM: 維護頁看到 succeeded、api 通了，帶 ?upgraded=1 回去
    RN->>RN: 第 7 步 清理
    SCH->>BE: 每分鐘讀進度檔
    BE->>BE: 紀錄 succeeded、清舊 last_check、重查新版
    BE->>US: POST /v1/report（result=succeeded）
    BE->>BE: 每步補寫 system_logs
```

| 步 | 誰 | 打哪支端點／跑哪支程式 | 要多久 | 失敗怎樣 |
|---|---|---|---|---|
| 1～2 | FE→BE | `POST /system/product-update/apply`，body `mode`（`now`／`scheduled`）、`at`、`with_agents`、`agent_uids` | — | 沒權限 403；包不在 412 `PRODUCT_UPDATE_PACKAGE_MISSING`；排程時間不在 1 分鐘後到 60 天內：400（`upgrade_order.py:134-143`） |
| 3 | BE | `_enqueue`（`upgrade_order.py:255-276`）：`verify_ready_path` 每段重驗清單與雜湊；`_schedule_job` 紀錄改 `scheduled`、`job_id`、`not_before` | 5GB 約 20 秒（剛下載完的路徑跳過） | 驗不過 400；寫單失敗紀錄改回原狀態 |
| 4 | BE | `write_order`（`:114-117`）：`orders/<job_id>.json`，寫暫存再 rename | 毫秒 | — |
| 5 | 主機 | `guidant-upgrade-order.path`（`PathChanged`）→ `guidant-upgrade-order.service` → `guidantai upgrade --from-order` | 秒級 | path 單元沒裝或沒啟用：單一直留著，畫面停在「正在準備更新」 |
| 6 | 主機 | `upgrade_order.sh` `ord_load`、`ord_verify_package`（`:75-131`）：格式、包在 `incoming/`、本機印樣驗章、版號比前一段新 | 每段一次雜湊 | 驗不過：進度檔 `skipped`／`unchanged`、刪單 → 紀錄 `skipped` |
| 6' | 主機 | 排程單：`systemd-run` 一次性 timer，到點以 `--run` 回到第 6 步再驗一次（`:133-148`） | 到 `not_before` | 到點時單已刪＝已取消，什麼都不做 |
| 7 | 主機 | `ord_run_segment`（`:185-224`），跨版每段一次；旁邊每秒看 `.cancel` 旗標 | — | 一段失敗就停，停在中間版 |
| 8～15 | runner | 第 0～7 步，見 [upgrade-runner-spec.md](upgrade-runner-spec.md) §3～§5 | 第 3～6 步合計上限 3600 秒 | 第 0～2 步失敗：`skipped`；第 3～6 步：自動退回 → `rolled_back` 或 `rollback_failed` |
| 11' | 前門 | nginx `error_page 502 503 504 =503 /maintenance.html`；維護頁每 3 秒讀 `/upgrade-progress.json` | — | — |
| 14 | runner | `upgrade_switch.sh:145-146`：`succeeded` 寫在重建前門之前 | — | 寫完之後逾時不退回 |
| 15～16 | 前門 | `maintenance.js:331-358`：進度檔不是 `running`、`/api/1.0/version` 通了才 reload | 前門重建約數秒；等 fe healthy 上限 60 秒 | 退回也失敗時不放人 |
| 18 | 排程 | `product_update_order_sync`，每分鐘（`core/scheduler_update.py:39-67`） | — | 各段各自 try，回報出錯不擋 Agent 跟進 |
| 19 | BE | `sync_from_progress`（`upgrade_order.py:451-473`）；`after_switch` 讀 `last-switch.json`，清掉換版前的 `last_check` 並以新版重查（`:605-618`） | — | — |
| 20 | BE | `report_final_once`（`:486-513`）：紀錄是終態且還沒報過才 `POST /v1/report`（`target=host`） | 讀 30 秒 | 送不到每分鐘重試，結束超過 1 天放棄；只是統計 |
| 21 | BE | `log_steps_once`（`:562-585`）：每步一筆、整次結果一筆，事件碼 6239～6248 | — | 每張單只寫一次 |

**畫面輪詢也會同步**：`GET /status` 會呼叫 `after_switch(recheck=False)`，換接剛完成就同步紀錄、不等排程，但不打更新站（`product_update_app_service.py:130-134`）。

**取消**：管理員按「取消」→ `DELETE /system/product-update/apply`。單還在（主機還沒撿）＝刪單；主機已開跑且最後一步 <3＝寫 `orders/<job_id>.cancel`，主機收掉 runner、進度檔寫 `skipped`；第 3 步起 409 `PRODUCT_UPDATE_TOO_LATE_TO_CANCEL`（`upgrade_order.py:374-385`）。紀錄回 `ready`，包還在、可以重排。

**重試**：`rolled_back` 或 `skipped` 時按「重試」→ `POST /system/product-update/retry`，用 `incoming/` 同一個包從預檢重跑（`upgrade_order.py:368-371`）。

::: {.callout .warn}
**陷阱：主機不打主系統，結果最慢晚一分鐘**

主機只寫進度檔，主系統每分鐘讀一次（`core/scheduler_update.py:39-43`）。換接完成那一刻畫面靠 `last-switch.json`＋status 請求先同步；回報更新站與補寫系統紀錄只走排程。
:::

## 圖 3 Agent 跟進 {#agent nav="Agent 跟進"}

```{.mermaid cap="圖 3 — 主系統升完先推 1 台，成功再推其餘；Agent 不連外網，包只從主系統拿"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    autonumber
    participant SCH as 排程
    participant BE as BE
    participant AG as Agent
    participant AH as Agent 主機
    participant US as 更新站
    SCH->>BE: 每分鐘 tick（進度檔 succeeded、to＝現行版）
    BE->>BE: 建跟進列：第 1 批 1 台 dispatched、其餘第 2 批 pending
    BE-)AG: POST /update/notify（叫它立刻心跳）
    AG->>BE: POST /agents/heartbeat
    BE-->>AG: data.update＝target_version、bundle_sha256、size
    AG->>BE: GET /agents/update/bundle（續傳）
    AG->>AG: 驗大小與 sha，寫 agent-upgrade-order.json
    AH->>AH: path 單元觸發 upgrade-host order
    AH->>AH: 包搬進 staging、驗 sha、安全解包、載映像、舊版驗新版章
    AH->>AH: install.sh upgrade（1 預檢、2 停接、3 換設定、4 重建容器、5 健康、6 清理）
    AH->>AH: 寫 Agent 進度檔（同 job_id）
    AG->>AG: 下次心跳前讀進度檔
    AG->>BE: 心跳帶 upgrade_result（succeeded 或 failed）
    BE->>BE: 跟進列改 succeeded 或 failed
    SCH->>BE: 下一分鐘 _advance
    BE->>BE: 前一批全成功放下一批；有失敗其餘 paused
    SCH->>US: POST /v1/report（target=agent），送達才記 reported_at
```

| 步 | 誰 | 打哪支端點／跑哪支程式 | 要多久 | 失敗怎樣 |
|---|---|---|---|---|
| 1 | 排程 | `_agent_rollout_tick` → `AgentRolloutService.tick`（`core/scheduler_update.py:86-101`；`agent_rollout_service.py:447-459`） | 每分鐘 | 例外只記 log，不擋紀錄同步 |
| 2 | BE | `_start_following`（`:461-481`）：更新單 `with_agents` 為 false 就不建；只取落後、`active`、未停用、勾選的；包要在 `upgrade/bundles/guidant-ai-<版>/agent/` | — | 包內沒有 Agent 包：記 warning、不跟進 |
| 3 | BE | `notify_in_background` → Agent 資料面 `POST /update/notify`（mTLS＋綁「方法 路徑」的短效 JWT） | 5 秒逾時（`agent_update_notify_client.py:23`） | 連不到只記 warning，等下一次心跳（預設 300 秒） |
| 4～5 | Agent→BE | `POST /api/1.0/agents/heartbeat`；回應 `data.update` 由 `update_for_agent`（`agent_rollout_service.py:369-386`）決定：列是 `dispatched`、仍落後、`host_version` 不比現行新、包版號＝`target_version` | — | 任一條件不過就不帶 `update`，Agent 什麼都不動 |
| 6 | Agent→BE | `GET /api/1.0/agents/update/bundle`（同一套判定，續傳） | 依頻寬 | 404＝已不在下發批，等下一次心跳；下載中斷留 `.part` 下次續；同一包失敗 1800 秒內不重下 |
| 7 | Agent | `update_sync.sync_once`（evidence-agent `guidant_agent/sync/app/update_sync.py:403-428`）：大小與 sha 對才寫單；單的 `job_id` 形狀 `agent-YYYYmmdd-HHMMSS-<6 hex>` | — | 大小或 sha 不符：刪檔、回報 `failed` |
| 8 | Agent 主機 | `guidant-agent-upgrade-order.path`（`PathChanged=<升級目錄>/agent-order`）→ `upgrade-host order`（`upgrade_order.sh:158-194`） | 秒級 | 單元沒裝：Agent 等 7200 秒判 `failed`「主機的升級單元可能沒裝或沒啟用」 |
| 9 | Agent 主機 | `_uo_precheck`、`_uo_stage_bundle`、`_uo_load_and_verify`（`:217-319`）：版號比現行新；包 `mv` 進 root 700 的 `agent-staging/`；列清單只收一般檔與目錄、解開不超過 3 倍＋1GB；只收 `guidant-ai-agent:<版>` 一個 tag；用**現行版**執行檔驗新版程式樹簽章 | 1.7GB 包數分鐘 | 寫 `agent-upgrade-host.json`（`failed` 或 `skipped`），進度檔 `unchanged` |
| 10 | Agent 主機 | `install.sh upgrade` → `upr_upgrade`（`upgrade_runner.sh:398-445`） | 預檢 120、套用 900、切換 1800、健康 600、清理 600；第 4～5 步合計 3600 秒 | 第 3 步起失敗自動退回（還原 `.env`、compose 檔，`up -d`） |
| 11 | Agent 主機 | 進度檔與主系統同契約；`job_id`＝升級單 `job_id` | — | — |
| 12～13 | Agent→BE | `_order_outcome`（`update_sync.py:109-136`）判結果；心跳帶 `upgrade_result={version,status,at,error}`，同一筆一直重送到有新結果 | 下一次心跳 | 進度檔 `skipped`：不回報，下次心跳重來 |
| 14 | BE | 心跳 observer `record_upgrade_result` → `record_result`（`agent_rollout_service.py:394-412`）：版號要等於 `target_version` | — | 重送的舊結果不重寫 |
| 15～16 | 排程 | `_advance`（`:496-518`） | 每分鐘 | 前一批有失敗：其餘 `paused`，管理員要按「繼續」 |
| 17 | 排程 | `report_agent_results`（`core/plugins/_agent_update.py:58-77`）→ `POST /v1/report`（`target=agent`、`agent_fingerprint`），身分用主機授權 | 讀 30 秒 | 送不到每分鐘重試，結束超過 1 天放棄 |

**不跟進時**：確認視窗不勾「主系統更新完成後，接著更新 Agent」，主系統升完 Agent 不動；之後在 Agent 區勾選推送（`POST /system/product-update/agents/push`，可帶 10 分鐘後到 60 天內的排程時間），或「稍後提醒」7 天（`POST …/agents/remind-later`）。

::: {.callout .warn}
**限制：Agent 跟進只支援 docker compose 形態**

原生形態收到 `update` 會回報 `failed` 說明原因，不默默不理，否則那一批永遠停在下發中（`update_sync.py:13-14`）。從沒有自動跟進能力的舊版要先手動升一次；以 `install.sh --upgrade` 指向 IT 自己解開的目錄升主系統時，`bundles/` 下沒有 Agent 包，Agent 不會跟進（`handoff/fr167-STATE.md` §3）。
:::

## 圖 4 發版 {#release nav="發版"}

```{.mermaid cap="圖 4 — CI 只能登記草稿；蓋章只在決策者電腦，更新站沒有私鑰"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    autonumber
    participant CI as CI
    participant US as 更新站
    participant R2 as R2
    participant DEC as 決策者
    participant CLI as CLI
    participant IN as 內部先裝主機
    participant BE as 客戶主系統
    CI->>CI: rehearse-upgrade（同一支 build_bundle.sh 打包、排練升級）
    CI->>CI: package：SBOM 門檻、build_bundle.sh、推 Harbor
    CI->>US: POST /admin/releases/register-draft（CI token）
    US-->>CI: 201 draft＋R2 key（bundle、sha256、manifest）
    CI->>R2: 上傳包、.sha256、release.json（未簽）
    DEC->>US: 後台 PUT internal-targets（內部先裝指紋）
    IN->>US: POST /v1/check（草稿只回給名單內的機器）
    IN->>IN: 照圖 1、圖 2 升級
    IN->>US: POST /v1/report（succeeded）
    DEC->>CLI: guidant-release publish {版}
    CLI->>US: GET /admin/releases/{版}（取草稿與內部先裝結果）
    CLI->>CLI: 檢查全部成功、確認、私鑰蓋章、用印樣驗自己的章
    CLI->>US: POST /admin/releases/{版}/publish（signed_manifest、kid）
    US-->>CLI: published
    BE->>US: POST /v1/check
    US-->>BE: available＋signed_manifest
    BE->>BE: 用本機印樣驗章（接圖 1）
```

| 步 | 誰 | 打哪支端點／跑哪支程式 | 要多久 | 失敗怎樣 |
|---|---|---|---|---|
| 1 | CI | `.gitlab-ci.yml` `rehearse-upgrade`（`:199-213`）：`build_bundle.sh` 後以 `deploy_e2e.sh` 從指定舊版升上來 | 上限 2 小時 | 排練不過不進 package |
| 2 | CI | `package`（`:215-225`）：`sbom.sh`（白名單外 Critical 就停）→ `build_bundle.sh`（公鑰表沒有 PROD 鑰就紅）→ `push_harbor.sh` | Nuitka 編譯約 15～20 分 | 任一步紅就不出包 |
| 3～4 | CI→更新站 | `scripts/build/publish_draft.sh` → `POST /admin/releases/register-draft`，body＝`release.json`（`version`、`min_base_version`、`sha256`、`size`、`has_security_fix`、`min_agent_version`）；token 走 header 檔不上命令列 | 秒級 | 非 201 就紅；已發布或撤回的版號 409 `not_draft` |
| 5 | CI→R2 | `aws s3 cp`：先大包、再 `.sha256`、最後 `release.json`；key 一律用更新站回的 `upload.*`（`v<主版號>/<版號>/<檔名>`），腳本不自己拼 | 依包大小 | 中途失敗時沒有 `release.json` 的草稿不會被當成完整 |
| 6 | 決策者 | 後台 `PUT /admin/releases/<版>/internal-targets`，最多 20 個指紋，只有草稿能改 | — | 換包（sha256 不同）時舊的先裝結果不算數 |
| 7～9 | 內部先裝主機 | `/v1/check`：草稿只回給 `internal_fingerprints` 列到的機器（`update-center src/releases.ts:65-70`）；升完 `/v1/report` | 一次完整升級 | 先裝失敗：決策者不能發布 |
| 10～12 | CLI | `guidant-release publish <版>`（`update-center cli/commands.ts:164-195`）：取草稿 → 檢查內部先裝全部成功 → 輸入版號確認 → 解鎖私鑰 → `buildPayload` 蓋章（主力章，有下一把就連署並夾帶 `endorsed_keys`）→ 用印樣驗自己剛蓋的章 | 互動 | 先裝未全成功：不蓋章；私鑰與 `.pub` 對不上：在這裡擋下 |
| 13～14 | CLI→更新站 | `POST /admin/releases/<版>/publish`，body `{signed_manifest, kid}`；需 Cloudflare Access 身分 | 秒級 | 草稿先裝未完成 409 `internal_not_installed`；撤回的 409；kid 已作廢 409 `kid_revoked` |
| 15～17 | 客戶主系統 | 圖 1：`/v1/check` 拿到 `signed_manifest`，用 `/etc/guidant/release-keys/<kid>.pub` 自己驗 | — | 更新站回什麼都不信，驗不過整段不認 |

**蓋章內容**（`update-center cli/envelope.ts:51-74`）：`payload_type`、`version`、`major`、`min_base_version`、`sha256`、`size`、`has_security_fix`、`issued_at`、`kid`，選填 `min_agent_version`、`ops_sha256`／`ops_size`、`endorsed_keys`。CLI 提示清單 90 天後到期、到期前要重新蓋章（`MANIFEST_VALID_DAYS=90`，`cli/envelope.ts:21`）；已發布的版本可同版重簽。

**換鑰順序**：客戶主機驗章只用自己已裝的印樣。新印樣隨包安裝（第 0 步 `release_keys_install`），所以要先用舊章發一版、這一版把新印樣裝上去，下一版才能用新章（`scripts/installer/lib/release_keys.sh:5-7`）。

::: {.callout .warn}
**現況缺口：CI 正式發布路徑不帶維運指令小檔**

`register-draft` 的輸入只收 `version`、`min_base_version`、`sha256`、`size`、`has_security_fix`、`min_agent_version`（`update-center src/releases.ts:196-213`），`publish_draft.sh` 也只傳三個檔、不傳 `.ops.tar.gz`。所以走正式 CLI 發布的版本，清單裡沒有 `ops_sha256`，客戶主機第 0 步會從整包抽維運指令（5GB 約兩分多，時限依包大小每 GB 60 秒）。更新站下載端點已認得 `artifact: "ops"`、CLI 的 `buildPayload` 也會帶 ops 兩欄，缺的是草稿登記與上傳這一段。
:::

::: {.callout .warn}
**現況缺口：主系統驗清單沒有判 90 天效期**

`package_verifier.verify_manifest` 只驗 kid、簽章、版號遞增、主版號（`:206-218`）；`issued_at` 只解析、不比對。主機 shell 端也不判（`upgrade_order.sh:96`「清單不設有效期」）。90 天目前只是 CLI 對決策者的提醒。
:::

## 相關文件 {#refs nav="相關"}

| 文件 | 內容 |
|---|---|
| [design.md](design.md) | 設計定案 D1～D31、端到端驗收 |
| [upgrade-runner-spec.md](upgrade-runner-spec.md) | 主機升級程式每一步、進度檔、更新單、systemd、目錄佈局 |
| [state-machines-spec.md](state-machines-spec.md) | 更新紀錄、Agent 跟進列、進度檔的狀態與畫面對照 |
| `handoff/fr167-STATE.md` §3 | 已落地的契約（與 design 不同處） |
| update-center repo `README.md` | 更新站端點與後台完整說明 |
